diff --git a/Dockerfile b/Dockerfile
index 0aa3db65..fa7674ed 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,14 +1,18 @@
-# Use Python 2.7 as the base image
-FROM python:2.7
+FROM python:3.12-alpine
WORKDIR /app
COPY requirements.txt /app/
-RUN pip install --no-cache-dir -r requirements.txt
+RUN pip install --no-cache-dir --requirement requirements.txt
+
+RUN addgroup -S docs && adduser -S -G docs docs
COPY ./docs/ /app/docs/
COPY mkdocs.yml /app/
+RUN chown -R docs:docs /app
EXPOSE 8000
+USER docs
+
CMD ["mkdocs", "serve", "-a", "0.0.0.0:8000"]
diff --git a/Makefile b/Makefile
index 00c9f9f8..e09c7b80 100644
--- a/Makefile
+++ b/Makefile
@@ -1,5 +1,6 @@
GO := go
-AIR := ${GOPATH}/bin/air
+AIR := $(shell $(GO) env GOPATH)/bin/air
+BEAST_BIN := $(if $(BEAST_OUTPUT),$(BEAST_OUTPUT),$(shell $(GO) env GOPATH)/bin/beast)
pkgs = $(shell $(GO) list ./... | grep -v vendor)
@@ -12,12 +13,11 @@ help:
@echo "* check_format: Check for formatting errors using gofmt"
@echo "* format: format the go files using go_fmt in the project directory."
@echo "* test: Run tests for beast"
- @echo "* tools: Set up required tools for beast which includes - docker-enter, importenv"
@echo ""
# Build beast
-build: tools
- @./scripts/build/build.sh
+build:
+ @BEAST_OUTPUT="$(BEAST_BIN)" ./scripts/build/build.sh
# Run development environment
dev:
@@ -25,7 +25,9 @@ dev:
@$(AIR)
cmdref: build
- @${GOPATH}/bin/beast cmdref
+ @rm -rf docs/cmdref
+ @"$(BEAST_BIN)" cmdref --reference-directory docs/cmdref
+ @sed -i '$${/^$$/d;}' docs/cmdref/*.md
# Check go formatting
check_format:
@@ -34,8 +36,15 @@ check_format:
# Add more tests later on for this
test: check_format
- @echo "[*] Running tests for example challenges"
- @./scripts/test/test_examples.sh
+ @echo "[*] Running unit tests"
+ @$(GO) test ./...
+
+test-race:
+ @echo "[*] Running race-enabled tests"
+ @$(GO) test -race ./...
+
+integration-test: build
+ @BEAST_RUN_INTEGRATION=1 ./scripts/test/test_examples.sh
# Format code using gofmt
format:
@@ -47,37 +56,21 @@ govet:
@echo "[*] Vetting code, checking for mistakes"
@$(GO) vet $(pkgs)
-# Ensure that the required tools are installed for beast to work
-tools:
- @if ! test -x "`which nsenter 2>&1;true`"; then \
- echo 'Error: nsenter is not installed, Install it first' >&2 ; \
- fi
-
- @if ! test -x "`which docker-enter 2>&1;true`"; then \
- echo 'Warn: docker-enter is not installed, building....' >&2 ; \
- sudo cp ./scripts/docker-enter "/usr/bin/" ; \
- sudo cp ./scripts/docker_enter "/usr/bin/"; \
- sudo chown root "/usr/bin/docker_enter"; \
- sudo chmod u+s "/usr/bin/docker_enter"; \
- fi
-
- @if ! test -x "`which importenv 2>&1;true`"; then \
- echo 'Warn: importenv is not installed, building....' >&2 ; \
- sudo gcc -o "/usr/bin/importenv" ./scripts/importenv.c ; \
- fi
-
requirements:
@echo ">>> Building beast extras..."
@./scripts/build/extras.sh
-docs:
+swagger:
+ @$(GO) run github.com/swaggo/swag/cmd/swag@v1.16.4 init --generalInfo main.go --dir api --output api/docs --parseDependency
+
+docs: swagger
@rm -rf site/
@echo ">>> Building Documentation"
- @mkdocs build
- @python scripts/tools/swagger-docs.py
+ @mkdocs build --strict
+ @python3 scripts/tools/swagger-docs.py
installenv:
@echo 'Setting up environment for beast.'
@./scripts/installenv.sh
-.PHONY: build format test check_format tools docs installenv
+.PHONY: build cmdref format test test-race integration-test check_format swagger docs installenv govet requirements
diff --git a/README.md b/README.md
index f1182bd2..1134b122 100644
--- a/README.md
+++ b/README.md
@@ -1,204 +1,121 @@
-
-
-
+# Beast
-Jeopardy-style CTF challenge deployment and management tool.
+Beast is a Linux service for building, deploying, and operating jeopardy-style CTF challenges. It exposes an HTTPS API and CLI, stores durable state in PostgreSQL, uses Redis for coordination and instance expiry, and deploys challenges through local or remote Docker daemons.
-
-
-
-
-
-
-
-
-
-
-
+## Security model
-## Contents
+Beast executes organizer-supplied challenge build contexts and controls Docker. Docker socket access and membership in the Docker group are effectively root-equivalent. Run Beast on a dedicated host or VM, use a dedicated unprivileged account, restrict management API access, and treat challenge authors as trusted build-code contributors. Containers reduce risk but are not a security boundary against a hostile kernel exploit.
-- [Overview](#overview)
-- [Features](#features)
-- [Supported Challenge](#supported-challenges)
-- [Download](#download)
-- [Tech Stack](#tech-stack)
-- [Development](#development)
-- [Contributing](#contributing)
-- [Contact](#contact)
+The controller requires TLS. Non-loopback PostgreSQL connections require `sslmode = "verify-full"` and a CA file; non-loopback Redis connections require TLS. SSH workers verify `known_hosts`, and private keys/configuration files must be regular files with mode `0600`.
-## Overview
+## Requirements
-Beast is a service that runs on your host(maybe a bare metal server or a cloud instance) and helps manage deployment, lifecycle, and health check of CTF challenges. It can also be used to host Jeopardy-style CTF competition.
+- Linux
+- Go 1.23 or newer
+- Docker Engine with a reachable daemon
+- Git and Make
+- PostgreSQL
+- Redis with ACL support
-Visit [beast.sdslabs.co](https://beast.sdslabs.co/) for the more details and documentation
+Use trusted operating-system packages. The setup scripts do not install system packages or pipe remote scripts into a shell.
-If you're looking for the source code of playCTF, the frontend powered by Beast, visit https://github.com/sdslabs/playCTF.
-
-## Features
-
-- Git based source of truth.
-- Container based isolation
-- Easy configuration
-- SSH support for challenge instances
-- Command line interface to perform actions and host competitions
-- REST API interface for the entire ecosystem
-- An optional automated health check service to periodically check the status of challenges and report if there is some sort of problem with one.
-- Single source of truth for all the static content related to all the challenges making it easy to debug, monitor and manage
- static content through a single interface.
-- Support for various notification channels like slack, discord.
-- Everything embedded to a single go binary which can be easily used anywhere.
-
-For more details on the features, refer to [Features](./docs/Features.md)
-
-## Supported Challenges
-
-As of now beast support the following type of challenges:
-
-- Service - A service hosted on beast container instance
-- Web - Web based challenges for various languages including PHP, Python, Node.js etc.
-- Static - Challenges with static files, this may include forensics challenges.
-- Bare - Highly customisable challenges.
-- Docker - Challenges which are provided with their own docker file.
-
-## Download
-
-Assuming you have the [docker](https://www.docker.com/) installed, head over to Beast's [releases](https://github.com/sdslabs/beast/releases) page and grab the latest binary and `setup.sh` script.
-
-Run the `setup.sh` script once. It will setup the required folders and configuration files for you.
-
-Run the downloaded binary with
+## Install and initialize
```bash
-$ ./beast run -v
+git clone https://github.com/sdslabs/beastv4.git
+cd beastv4
+./scripts/installenv.sh
+make build
+beast init
```
-## Tech Stack
-
-Beast is written completely in Golang and comes with a clean REST API interface to trigger actions or interact with underlying functionalities.
-The REST API server is implemented using `gin` go library and uses JWT as an authentication mechanism. Being written in go, Beast is compiled into
-a single binary which can run on any linux distribution.
-
-Beast uses Docker as a container runtimes to run challenges in a sandboxed environment. Note that container does not provide a very strong isolation, but our host is safe as long as there is no 0-day in linux kernel itself. Even though container provide a security layer for the challenges, we follow some practices to harden those security measures.
-
-We use Swagger for automatic generation of API documentation and you can find the docs at `/api/docs/index.html` from beast server root.
-
-To save the state of the deployments and challenges beast uses SQLite as a database, all the information ranging from challenge deployment state to allocated ports and author information is stored in this database. This database is created automatically in the root of your beast configuration directory.
-
-## Development
+`beast init` creates private state under `$HOME/.beast`, generates or validates the TLS certificate and configuration, provisions the configured PostgreSQL database and Redis ACL user, and can create the first administrator. It prompts before privileged datastore operations.
-Beast go version is under development; follow the below instructions to get started.
+For a non-interactive filesystem/bootstrap starting point, run `./setup.sh`, review the generated `$HOME/.beast/config.toml`, then run `beast init`. The setup script generates unique JWT, PostgreSQL, and Redis secrets but does not install or start those services.
-- Make sure you have docker up and running.
-- Install go [1.18.X](https://golang.org/dl/) or above
-- Make sure that `GO111MODULES` environment variable should be set to `on`, or do `export GO111MODULES=on`
-- Clone the repository.
-- Jump to `$GOPATH/src/github.com/sdslabs/beast/` and start hacking.
+Start the controller:
```bash
-$ go version
-go version go1.18 linux/amd64
-
-$ export GO111MODULES=on
-
-$ git clone git@github.com:sdslabs/beast.git
+beast run --health-probe
+```
-$ cd beast && make help
-BEAST: An automated challenge deployment tool for backdoor
+The default endpoint is `https://localhost:5005`. For a locally generated certificate, pass its CA/certificate explicitly to clients. Do not disable TLS verification.
-* build: Build Beast and copy binary to PATH set for go build binaries.
-* dev: Run development environment with hot-reloading enabled
-* check_format: Check for formatting errors using gofmt
-* format: format the go files using go_fmt in the project directory.
-* requirements: Build beast extra artifacts requirements
-* test: Run tests for beast
-* tools: Set up required tools for Beast which includes - docker-enter, importenv
+```bash
+beast getauth --host https://localhost:5005 \
+ --ca-file "$HOME/.beast/secrets/tls.crt" \
+ --username
```
-**All the dependencies are already vendored with the project, so no need to install any dependencies**. The project uses go modules from go 1.18.X of dependency management. Make sure you vendor any library used using `go mod vendor`
+## Configuration
-### Building
+The complete annotated example is [`_examples/example.config.toml`](_examples/example.config.toml). Important rules:
-To build Beast from Source use the Makefile provided.
+- `$HOME/.beast/config.toml` must be a non-symlink regular file with mode `0600`.
+- `jwt_secret` must contain at least 32 bytes.
+- TLS certificate/key paths are mandatory; the private key must be mode `0600`.
+- Active remote workers need a mode-`0600` SSH key and a populated `known_hosts` file.
+- Resource defaults are hard ceilings for per-challenge overrides.
+- CORS origins must be explicit HTTPS origins (loopback HTTP is accepted for development only).
-- `make build`
+## Challenge workflow
-This will build Beast and place the binary in `$GOPATH/bin/` and copy the necessery tools to the desired place. To build this in production make sure you also have built the static-content docker image in `/extras/static-content`
+Create a strict, parseable static-challenge scaffold in an empty directory:
-To run the API server for Beast, use the command `beast run -v`
-
-### Hot reloading support
+```bash
+mkdir my-challenge && cd my-challenge
+beast new
+```
-- run the following command to install `air` (hot reload support)
+Edit `beast.toml`, place downloadable files in `public/`, and validate before deployment:
```bash
-curl -sSfL https://raw.githubusercontent.com/cosmtrek/air/master/install.sh | sh -s -- -b $(go env GOPATH)/bin
+beast verify --local-directory "$PWD"
```
-- modify `full_bin` in `.air.toml` for changing the arguments to run beast, default being `beast run -nv`
-- run `make dev` to start `beast` in development mode
+Controller-local path deployment (`beast challenge deploy --local-directory …` and its API equivalent) is administrator-only. Authors can upload a bounded ZIP whose `author`/`maintainer` email matches their account, or manage synchronized challenges they own.
-### Testing
+Challenge names, referenced files, Compose build contexts, setup scripts, assets, and environment-value files are validated and must remain inside the challenge directory. Compose files accept a constrained schema and only port-variable interpolation; privileged, host-network, host-PID/IPC, device, socket, and unsafe mount controls are rejected.
-To test use the sample challenges in the `_examples` directory. Use the challenge simple and try to deploy it using
-Beast. Follow the below instructions.
+See the [challenge configuration guide](docs/ChallConfig.md) and [examples](_examples/README.md).
-You can find swagger API documentation here: http://localhost:5005/api/docs/index.html
+## Development
```bash
-# Build beast
-$ make build
-
-# Run beast server
-# Beast server will start running on port 5005 port by default
-$ beast run -v
-
-# In another terminal Start the local deployment of the challenge, using the directory
-$ curl -X POST localhost:5005/api/manage/deploy/local/ --data "challenge_dir="
-
-# Or you can directly deploy the challenge using name in the remote
-$ curl -X POST --data "action=deploy&name=" localhost:5005/api/manage/challenge/
-
-# Wait for Beast to finish the image build and deployment of the challenge
-# This might take some time. Have some snacks ready!
-# Try connecting to the deployed service
-$ nc localhost 10001
-
---- Menu ---
-1.New note
-2.Delete note
-3.Help
-4.Exit
-choice > 4
+make check_format
+go vet ./...
+go test ./...
+go test -race ./...
+make build
```
-### Building documentation
-
-The documentation for the project lies in [/docs](/docs). We use `mkdocs` to automatically generate documentation from markdown. The configuration file for the same can be found at [mkdocs.yml](/mkdocs.yml). To view the documentation locally, create a virtual environment locally and install [requirements](/requirements-dev.txt).
+PostgreSQL concurrency tests run when `BEAST_TEST_PG_DSN` is set. Redis integration tests run when `BEAST_TEST_REDIS_ADDR` and related credentials are set. The example deployment harness is destructive and opt-in:
```bash
-$ virtualenv venv && source venv/bin/activate
-
-$ pip install -r requirements.txt
+BEAST_RUN_INTEGRATION=1 make integration-test
+```
-$ mkdocs serve
+Build documentation with pinned Python dependencies from `requirements.txt`:
-Serving on http://127.0.0.1:8000
+```bash
+python3 -m venv .venv
+. .venv/bin/activate
+pip install --requirement requirements.txt
+make cmdref
+make docs
```
-## Contributing
+## Teardown
-We are always open for contributions. If you find any feature missing, or just want to report a bug, feel free to open an issue and/or submit a pull request regarding the same.
+`scripts/teardown.sh` verifies the controller lock and process ownership before sending `SIGTERM`. It does not undeploy challenges or delete external PostgreSQL/Redis data.
-For more information on contribution, check out our
-[docs](./docs/Contribution.md).
-
-## Contact
+```bash
+./scripts/teardown.sh # stop only
+./scripts/teardown.sh --purge-data # also remove $BEAST_HOME or $HOME/.beast
+```
-If you have a query regarding the product or just want to say hello then feel
-free to visit [chat.sdslabs.co](https://chat.sdslabs.co) or drop a mail at
-[contact@sdslabs.co.in](mailto:contact@sdslabs.co.in)
+The purge option is intentionally destructive for local Beast state and refuses unsafe target paths.
----
+## License
-Made with :heart: by [SDSLabs](https://sdslabs.co)
+[Apache License 2.0](LICENSE.md)
diff --git a/Vagrantfile b/Vagrantfile
index 39a8515d..7032fc6c 100644
--- a/Vagrantfile
+++ b/Vagrantfile
@@ -33,8 +33,7 @@ Vagrant.configure(VAGRANTFILE_VERSION) do |config|
config.vm.hostname = "beast"
- # The default box for the machine is ubuntu/bionic64
- config.vm.box = "ubuntu/bionic64"
+ config.vm.box = "ubuntu/noble64"
# The Beast environment runs on 9991 on the guest.
host_port = 5005
@@ -54,25 +53,25 @@ Vagrant.configure(VAGRANTFILE_VERSION) do |config|
# Provider-specific configuration so you can fine-tune various
# backing providers for Vagrant. These expose provider-specific options.
config.vm.provider "virtualbox" do |vb, override|
- override.vm.box = "ubuntu/bionic64"
+ override.vm.box = "ubuntu/noble64"
# Customize the amount of memory on the VM:
vb.memory = vm_memory
end
config.vm.provider "hyperv" do |h, override|
- override.vm.box = "bento/ubuntu-18.04"
+ override.vm.box = "bento/ubuntu-24.04"
h.memory = vm_memory
h.maxmemory = vm_memory
h.cpus = vm_num_cpus
end
config.vm.provider "parallels" do |prl, override|
- override.vm.box = "bento/ubuntu-18.04"
- override.vm.box_version = "202005.21.0"
+ override.vm.box = "bento/ubuntu-24.04"
prl.memory = vm_memory
prl.cpus = vm_num_cpus
end
+ config.vm.provision "dependencies", type: "shell", path: "scripts/provision/dependencies.sh", privileged: true
config.vm.provision "docker"
config.vm.provision "env", type: "shell", path: "scripts/installenv.sh", privileged: false
config.vm.provision "setup", type: "shell", after: "env", path: "scripts/provision/setup.sh", privileged: false
diff --git a/_examples/.static.beast.htpasswd b/_examples/.static.beast.htpasswd
deleted file mode 100644
index 76ddfc7d..00000000
--- a/_examples/.static.beast.htpasswd
+++ /dev/null
@@ -1,3 +0,0 @@
-# Password is fristonio_beast_static_pass
-# Username fristonio
-fristonio:$apr1$SmANaQLf$LSOCPJhgYsqs6ayf.QS3K.
diff --git a/_examples/README.md b/_examples/README.md
index c48d6c6d..bb3e2b8b 100644
--- a/_examples/README.md
+++ b/_examples/README.md
@@ -1,26 +1,25 @@
# Examples
-> This directory contains a few challenges example for beast, which are properly tested and should work out of the box.
+These directories demonstrate Beast challenge formats. They contain public test flags and intentionally weak sample services; run them only on disposable development workers.
-### Configuration file samples:
+- `static-chall`: static-only content.
+- `service`, `xinetd-service`: service challenges.
+- `web-php`, `web-php-mysql`: generated web challenges.
+- `bare-docker`: custom Dockerfile.
+- `compose-type`: constrained Compose deployment.
+- `instanced-service`, `instanced-compose`: per-user instances.
+- `simple`: generated bare environment.
-* [Beast global configuration sample](./example.config.toml)
-* [Beast static container authentication file](./.static.beast.htpasswd)
+Validate before deployment:
-### Sample Challenges
-
-* [Simple Challenge - Bare](./simple)
-* [PHP Web challenge](./web-php)
-* [PHP Web challenge with MySQL](./web-php-mysql)
-* [Challenge with Static files only](./static-chall)
-* [Xinted Service challenge with custom xinetd config](./xinetd-service)
-* [Service challenge with auto-generated xinetd config](./service)
-* [A bare challenge using docker](./docker-type)
-* [Docker compose challenge](./compose-type)
+```bash
+beast verify --local-directory "$PWD/_examples/service"
+```
-To test any of the above challenges, cd to \_example directory and use the below command:
+Then deploy with the local CLI path:
```bash
-$ curl -X POST localhost:5005/api/manage/deploy/local/ \
- --data "challenge_dir=$PWD/"
+beast challenge deploy --local-directory "$PWD/_examples/service"
```
+
+Both commands load the operator's validated `$HOME/.beast/config.toml`. Local deployment is an administrator/controller-host workflow and still uses the configured Docker worker, resource ceilings, PostgreSQL, and Redis.
diff --git a/_examples/bare-docker/beast.toml b/_examples/bare-docker/beast.toml
index 10f4f65d..f7d1fd39 100644
--- a/_examples/bare-docker/beast.toml
+++ b/_examples/bare-docker/beast.toml
@@ -1,7 +1,6 @@
[author]
name = "fristonio"
email = "contact+fristonio@sdslabs.co.in"
-ssh_key = "ssh-rsa AAAAB3NzaC1y"
[challenge.metadata]
name = "docker-type"
@@ -23,7 +22,7 @@ ports = [10005]
default_port = 10005
[resource]
-cpu_shares = 1024
+cpu_shares = 512
memory_limit = 536870912
pids_limit = 100
cpuslimit = 0.25
diff --git a/_examples/compose-type/README.md b/_examples/compose-type/README.md
index d1f30a63..28d1731e 100644
--- a/_examples/compose-type/README.md
+++ b/_examples/compose-type/README.md
@@ -1,17 +1,10 @@
-# Docker Compose example (PHP + MySQL)
+# Compose example (PHP + MySQL)
-This example demonstrates a multi-container challenge deployed via Docker Compose.
-
-- `beast.toml` enables Compose mode using `docker_compose = "docker-compose.yml"`.
-- `docker-compose.yml` defines an `app` container and a `mysql` container.
-
-Deploy locally:
+This example uses Beast's constrained Compose schema. `${APP_PORT}` is allocated by Beast and mapped to the web container. Both services declare CPU, memory, PID, capability, and `no-new-privileges` controls.
```bash
-curl -X POST localhost:5005/api/manage/deploy/local/ \
- --data "challenge_dir=$PWD/_examples/compose-type"
+beast verify --local-directory "$PWD/_examples/compose-type"
+beast challenge deploy --local-directory "$PWD/_examples/compose-type"
```
-After deployment, open:
-
-- http://localhost:10020
+Use `beast challenge show compose-type` to obtain the selected worker and allocated port. The challenge application itself serves plain HTTP; do not confuse it with the HTTPS Beast management API.
diff --git a/_examples/compose-type/beast.toml b/_examples/compose-type/beast.toml
index 31698fa3..04113b03 100644
--- a/_examples/compose-type/beast.toml
+++ b/_examples/compose-type/beast.toml
@@ -1,7 +1,6 @@
[author]
name = "example"
email = "example@local"
-ssh_key = "ssh-rsa AAAAB3NzaC1y"
[challenge.metadata]
name = "compose-type"
diff --git a/_examples/compose-type/docker-compose.yml b/_examples/compose-type/docker-compose.yml
index c945a976..e99c3c67 100644
--- a/_examples/compose-type/docker-compose.yml
+++ b/_examples/compose-type/docker-compose.yml
@@ -1,6 +1,12 @@
services:
app:
build: .
+ cap_drop: [ALL]
+ cap_add: [CHOWN, SETGID, SETUID]
+ security_opt: [no-new-privileges:true]
+ mem_limit: 256m
+ cpus: 0.125
+ pids_limit: 50
ports:
- "${APP_PORT}:80"
environment:
@@ -13,6 +19,12 @@ services:
mysql:
image: mysql:8
+ cap_drop: [ALL]
+ cap_add: [CHOWN, DAC_OVERRIDE, SETGID, SETUID]
+ security_opt: [no-new-privileges:true]
+ mem_limit: 256m
+ cpus: 0.125
+ pids_limit: 50
environment:
MYSQL_DATABASE: my_db
MYSQL_USER: my_user
diff --git a/_examples/example.config.toml b/_examples/example.config.toml
index 8c916360..665e37f9 100644
--- a/_examples/example.config.toml
+++ b/_examples/example.config.toml
@@ -1,19 +1,16 @@
-# Authorized key file used by ssh daemon running on the host
-# This is used for forwarding ssh connection to docker containers, the
-# access to a container is only given to the author of the challenge.
-authorized_keys_file = "$HOME/.beast/beast_authorized_keys"
-
-# Directory which will contain all the autogenerated scripts by beast
-# These scripts are the heart to above authorized keys file. Each entry in authorized
-# keys file as a corresponding script which is executed during an SSH attempt.
-scripts_dir = "$HOME/.beast/scripts"
-
# Base OS image that beast allows the challenges to use.
-allowed_base_images = ["ubuntu:18.04", "ubuntu:16.04", "debian:jessie"]
+allowed_base_images = ["ubuntu:24.04", "debian:bookworm"]
# For authentication purposes beast uses JWT based authentication, this is the
# key used for encrypting the claims of a user. Keep this strong.
-jwt_secret = "beast_jwt_secret_SUPER_STRONG_0x100010000100"
+jwt_secret = "CHANGE_ME_GENERATED_BY_SETUP"
+
+# Public HTTPS origin of the optional static-content service. Leave empty when
+# challenge assets are not published through Beast.
+beast_static_url = ""
+
+# Minimum accepted value is 2m. Invalid/short values fall back to the default.
+remote_sync_period = "2m"
#Health Prober, if active, starts a health prober on a thread which checks for
#deployed challenges, containers and servers after every ticker_frequency period
@@ -24,10 +21,10 @@ health_prober = false
ticker_frequency = 3000
-# Container default resource limits for each challenge, this can be
-# Overridden by challenge configuration beast.toml file.
-default_cpu_shares = 1024
-default_memory_limit = 1024
+# Container defaults and hard ceilings for challenge overrides.
+default_cpu_shares = 512
+default_cpus_limit = 0.25
+default_memory_limit = 536870912
default_pids_limit = 100
# List of ip addresses of all the servers where challenge could be deployed for
@@ -43,6 +40,7 @@ username = "user1"
# Path to private SSH key for interacting with the server.
ssh_key_path = "/path/to/your/private/key1"
+known_hosts_file = "$HOME/.ssh/known_hosts"
# Port range for this server (format: START:END)
port_range = "30000:40000"
@@ -61,8 +59,8 @@ username = ""
# Path to private SSH key for interacting with the server. (Leave empty for localhost)
ssh_key_path = ""
-# Port range for this server (format: START:END) - uses local_host_port_range if empty
-port_range = ""
+# Port range for this worker (format: START:END)
+port_range = "10000:20000"
# Status of remote server to be used
active = true
@@ -104,32 +102,45 @@ ssh_key = "$HOME/.beast/secrets/key.priv"
active = false
[mail_config]
-from = "your-email@example.com"
-password = "your-email-password"
-smtpHost = "smtp.example.com"
-smtpPort = "587"
+from = ""
+password = ""
+smtpHost = ""
+smtpPort = ""
# Configuration to connect to Postgresql database
[psql_config]
user = "beast"
-password = "12345678"
+password = "CHANGE_ME"
dbname = "beast"
host = "localhost"
port = "5432"
-sslmode = "prefer"
+# Loopback development may use disable. Remote PostgreSQL must use verify-full
+# and a CA bundle in sslrootcert.
+sslmode = "disable"
+sslrootcert = ""
[redis_config]
host = "localhost"
port = "6379"
-password = ""
-user = ""
+password = "CHANGE_ME"
+user = "beast"
+# Remote Redis must enable TLS. ca_file may be empty only when system roots
+# trust the server certificate.
+tls = false
+ca_file = ""
+server_name = ""
[instance_config]
default_expiration = 300
max_extension = 600
max_instances_per_user = 3
+[server]
+tls_cert_file = "$HOME/.beast/secrets/tls.crt"
+tls_key_file = "$HOME/.beast/secrets/tls.key"
+allowed_origins = []
+
# The following fields are required only while hosting a competition on beast
# This section contains information about the competition to be hosted
# Structure of the sections with the acceptable fields are:
diff --git a/_examples/instanced-compose/README.md b/_examples/instanced-compose/README.md
index d6240e8d..79fa730e 100644
--- a/_examples/instanced-compose/README.md
+++ b/_examples/instanced-compose/README.md
@@ -1,89 +1,23 @@
-# Instanced Docker Compose Challenge Example
+# Instanced Compose example
-This is an example of an **instanced challenge using Docker Compose** - a multi-container challenge where each user gets their own isolated environment with a web server and database.
+This challenge creates a per-user PHP/MySQL Compose project. Beast substitutes the allocated `${INSTANCE_PORT}` in `docker-compose.yml`; no default-value or arbitrary environment interpolation is accepted.
-## Architecture
-
-```
-┌──────────────────────────────────────────┐
-│ User's Instanced Environment │
-│ ┌─────────────┐ ┌─────────────┐ │
-│ │ PHP/Apache │ ───▶ │ MySQL │ │
-│ │ (web) │ │ (db) │ │
-│ └─────────────┘ └─────────────┘ │
-│ │ │
-│ ▼ │
-│ Port: 31234 (dynamically assigned) │
-└──────────────────────────────────────────┘
-```
-
-## Key Configuration
-
-In `beast.toml`:
-
-```toml
-[challenge.metadata]
-instanced = true
-instance_expiration = 600 # 10 minutes
-
-[challenge.env]
-docker_compose = "docker-compose.yml"
-default_port = 8080
-```
-
-In `docker-compose.yml`, use the `INSTANCE_PORT` environment variable:
-
-```yaml
-services:
- web:
- ports:
- - "${INSTANCE_PORT:-8080}:80"
-```
-
-## Challenge Details
-
-This is a SQL injection challenge:
-
-1. The login form is vulnerable to SQL injection
-2. Bypass authentication to login as admin
-3. The flag is stored in the `secrets` table
-
-### Solution
-
-```
-Username: admin' OR '1'='1' --
-Password: anything
-```
-
-Or use UNION-based injection to extract data directly.
-
-## Testing Locally
+Validate and deploy the challenge definition:
```bash
-# Build and run locally (for testing)
-cd _examples/instanced-compose
-docker-compose up -d
-
-# Access at http://localhost:8080
+beast verify --local-directory "$PWD/_examples/instanced-compose"
+beast challenge deploy --local-directory "$PWD/_examples/instanced-compose"
```
-## Usage via Beast API
+Spawn an instance through the TLS API:
```bash
-# Spawn your instance
-curl -X POST -H "Authorization: Bearer $TOKEN" \
- http://localhost:8080/api/instances/instanced-compose/spawn
+curl --cacert "$HOME/.beast/secrets/tls.crt" \
+ --header "Authorization: Bearer $TOKEN" \
+ --request POST \
+ https://localhost:5005/api/instances/instanced-compose/spawn
+```
-# Response:
-# {
-# "instance_id": "abc123def456",
-# "challenge_name": "instanced-compose",
-# "hosted_address": "localhost",
-# "port": 31234,
-# "expires_at": "2024-01-15T10:40:00Z",
-# "ttl_seconds": 600
-# }
+Open `http://:` from the JSON response. Challenge traffic is plain HTTP in this example; the Beast management API remains HTTPS.
-# Access your instance
-open http://localhost:31234
-```
+The application intentionally contains SQL injection and public sample credentials/flags. Do not deploy it on production infrastructure.
diff --git a/_examples/instanced-compose/beast.toml b/_examples/instanced-compose/beast.toml
index b07b2df7..21f31667 100644
--- a/_examples/instanced-compose/beast.toml
+++ b/_examples/instanced-compose/beast.toml
@@ -1,7 +1,6 @@
[author]
name = "beast-admin"
email = "admin@beast.local"
-ssh_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ"
[challenge.metadata]
name = "instanced-compose"
@@ -28,7 +27,7 @@ docker_compose = "docker-compose.yml"
default_port_var = "INSTANCE_PORT"
[resource]
-cpu_shares = 1024
+cpu_shares = 512
memory_limit = 536870912
pids_limit = 100
cpuslimit = 0.25
diff --git a/_examples/instanced-compose/docker-compose.yml b/_examples/instanced-compose/docker-compose.yml
index 2f51b88c..e3891316 100644
--- a/_examples/instanced-compose/docker-compose.yml
+++ b/_examples/instanced-compose/docker-compose.yml
@@ -5,6 +5,12 @@ services:
build:
context: .
dockerfile: Dockerfile
+ cap_drop: [ALL]
+ cap_add: [CHOWN, SETGID, SETUID]
+ security_opt: [no-new-privileges:true]
+ mem_limit: 256m
+ cpus: 0.125
+ pids_limit: 50
ports:
- "${INSTANCE_PORT}:80"
environment:
@@ -14,10 +20,15 @@ services:
- DB_NAME=ctf
depends_on:
- db
- restart: unless-stopped
db:
image: mysql:5.7
+ cap_drop: [ALL]
+ cap_add: [CHOWN, DAC_OVERRIDE, SETGID, SETUID]
+ security_opt: [no-new-privileges:true]
+ mem_limit: 256m
+ cpus: 0.125
+ pids_limit: 50
environment:
- MYSQL_ROOT_PASSWORD=rootpass
- MYSQL_DATABASE=ctf
@@ -25,4 +36,3 @@ services:
- MYSQL_PASSWORD=challengepass
volumes:
- ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro
- restart: unless-stopped
diff --git a/_examples/instanced-service/README.md b/_examples/instanced-service/README.md
index 7710cbcf..969db20c 100644
--- a/_examples/instanced-service/README.md
+++ b/_examples/instanced-service/README.md
@@ -1,119 +1,29 @@
-# Instanced Service Challenge Example
+# Instanced service example
-This is an example of an **instanced challenge** - a challenge where each user gets their own dedicated container instance.
+Each authenticated user receives a dedicated service container and a host port allocated from the selected worker's `available_servers..port_range`. `default_port = 9999` identifies the container port; the global `[instance_config]` controls default expiration, maximum extension, and per-user counts.
-## Key Features
-
-- **Per-user isolation**: Each user spawns their own container
-- **Automatic expiration**: Instances expire after a configurable time (default: 5 minutes)
-- **Dynamic port allocation**: Ports are assigned from a configured range (not from the challenge config)
-
-## Configuration
-
-In `beast.toml`, the key settings for instanced challenges are:
-
-```toml
-[challenge.metadata]
-instanced = true # Enable instancing
-instance_expiration = 300 # Optional: override default expiration (in seconds)
-
-[challenge.env]
-# DO NOT specify ports for instanced challenges!
-# Instead, use default_port to indicate which container port to expose
-default_port = 9999
-```
-
-## Global Configuration
-
-In your Beast `config.toml`, configure the instance settings:
-
-```toml
-[instance_config]
-local_host_port_range = "10000-11000" # Host port range for instances
-default_expiration = 300 # Default TTL in seconds (5 minutes)
-max_extension = 600 # Maximum extension time (10 minutes)
-max_instances_per_user = 3 # Max concurrent instances per user
-```
-
-## API Usage
-
-### User Endpoints
-
-1. **Spawn an instance**:
- ```bash
- curl -X POST -H "Authorization: Bearer $TOKEN" \
- http://localhost:8080/api/instances/instanced-service/spawn
- ```
-
-2. **Get your instance**:
- ```bash
- curl -H "Authorization: Bearer $TOKEN" \
- http://localhost:8080/api/instances/instanced-service
- ```
-
-3. **Get all your instances**:
- ```bash
- curl -H "Authorization: Bearer $TOKEN" \
- http://localhost:8080/api/instances
- ```
-
-4. **Extend instance lifetime**:
- ```bash
- curl -X POST -H "Authorization: Bearer $TOKEN" \
- -d "seconds=300" \
- http://localhost:8080/api/instances/instanced-service/extend
- ```
-
-5. **Kill your instance**:
- ```bash
- curl -X DELETE -H "Authorization: Bearer $TOKEN" \
- http://localhost:8080/api/instances/instanced-service
- ```
-
-### Admin Endpoints
-
-1. **List all instances**:
- ```bash
- curl -H "Authorization: Bearer $ADMIN_TOKEN" \
- http://localhost:8080/api/admin/instances
- ```
-
-2. **Kill any instance**:
- ```bash
- curl -X DELETE -H "Authorization: Bearer $ADMIN_TOKEN" \
- http://localhost:8080/api/admin/instances/{instance_id}
- ```
-
-3. **Kill all instances for a challenge**:
- ```bash
- curl -X DELETE -H "Authorization: Bearer $ADMIN_TOKEN" \
- http://localhost:8080/api/admin/instances/challenge/instanced-service
- ```
+```bash
+export BEAST_URL=https://localhost:5005
+export BEAST_CA="$HOME/.beast/secrets/tls.crt"
-## Response Example
+curl --cacert "$BEAST_CA" \
+ --header "Authorization: Bearer $TOKEN" \
+ --request POST "$BEAST_URL/api/instances/instanced-service/spawn"
-When spawning an instance, you'll receive:
+curl --cacert "$BEAST_CA" \
+ --header "Authorization: Bearer $TOKEN" \
+ "$BEAST_URL/api/instances/instanced-service"
-```json
-{
- "instance_id": "a1b2c3d4e5f6",
- "challenge_name": "instanced-service",
- "hosted_address": "localhost",
- "port": 31234,
- "created_at": "2024-01-15T10:30:00Z",
- "expires_at": "2024-01-15T10:35:00Z",
- "ttl_seconds": 300
-}
-```
+curl --cacert "$BEAST_CA" \
+ --header "Authorization: Bearer $TOKEN" \
+ --request POST --data-urlencode 'seconds=300' \
+ "$BEAST_URL/api/instances/instanced-service/extend"
-Connect to your instance:
-```bash
-nc localhost 31234
+curl --cacert "$BEAST_CA" \
+ --header "Authorization: Bearer $TOKEN" \
+ --request DELETE "$BEAST_URL/api/instances/instanced-service"
```
-## Challenge Details
+The spawn response supplies `hosted_address`, `port`, and expiration data. Connect with `nc `.
-This example is a simple buffer overflow challenge:
-- The `vulnerable()` function uses `gets()` which doesn't check bounds
-- Overflow the 64-byte buffer to overwrite the return address
-- Redirect execution to the `win()` function to get the flag
+Administrator instance routes are documented in the live Swagger UI. This example contains a deliberately vulnerable program and a public test flag; use a disposable worker.
diff --git a/_examples/instanced-service/beast.toml b/_examples/instanced-service/beast.toml
index 695a8870..a00a2404 100644
--- a/_examples/instanced-service/beast.toml
+++ b/_examples/instanced-service/beast.toml
@@ -1,7 +1,6 @@
[author]
name = "beast-admin"
email = "admin@beast.local"
-ssh_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ"
[challenge.metadata]
name = "instanced-service"
@@ -29,10 +28,10 @@ default_port = 9999
apt_deps = ["gcc", "xinetd"]
setup_scripts = ["setup.sh"]
service_path = "pwn"
-base_image = "ubuntu:18.04"
+base_image = "ubuntu:24.04"
[resource]
-cpu_shares = 1024
+cpu_shares = 512
memory_limit = 536870912
pids_limit = 100
cpuslimit = 0.25
diff --git a/_examples/service/beast.toml b/_examples/service/beast.toml
index f0a3e554..5eb44884 100644
--- a/_examples/service/beast.toml
+++ b/_examples/service/beast.toml
@@ -1,7 +1,6 @@
[author]
name = "fristonio"
email = "contact+fristonio@sdslabs.co.in"
-ssh_key = "ssh-rsa AAAAB3NzaC1y"
[challenge.metadata]
name = "service"
@@ -27,7 +26,7 @@ ports = [10004]
default_port = 10004
[resource]
-cpu_shares = 1024
+cpu_shares = 512
memory_limit = 536870912
pids_limit = 100
cpuslimit = 0.25
diff --git a/_examples/simple/beast.toml b/_examples/simple/beast.toml
index 1a408966..6a0fd258 100644
--- a/_examples/simple/beast.toml
+++ b/_examples/simple/beast.toml
@@ -1,7 +1,6 @@
[author]
name = "hi"
email = "hi@gmail.com"
-ssh_key = "hi"
[challenge.metadata]
name = "simple"
@@ -25,7 +24,7 @@ ports = [10005]
default_port = 10005
[resource]
-cpu_shares = 1024
+cpu_shares = 512
memory_limit = 536870912
pids_limit = 100
cpuslimit = 0.25
diff --git a/_examples/static-chall/beast.toml b/_examples/static-chall/beast.toml
index 7d9c5560..dee0272e 100644
--- a/_examples/static-chall/beast.toml
+++ b/_examples/static-chall/beast.toml
@@ -1,13 +1,15 @@
[author]
name = "fristonio"
email = "contact+fristonio@sdslabs.co.in"
-ssh_key = "ssh-rsa AAAAB3NzaC1y"
[challenge.metadata]
name = "static-chall"
flag = "BACKDOOR{SAMPLE_FLAG}"
type = "static"
points=150
+maxPoints = 150
+minPoints = 50
+tags = ["easy", "web"]
[[challenge.metadata.hints]]
text = "simple_hint_1"
@@ -17,15 +19,11 @@ points = 10
text = "simple_hint_2"
points = 20
-maxPoints = 100
-minPoints = 50
-tags = ["easy", "web"]
-
[challenge.env]
static_dir = "static"
[resource]
-cpu_shares = 1024
+cpu_shares = 512
memory_limit = 536870912
pids_limit = 100
cpuslimit = 0.25
diff --git a/_examples/static-chall/static/index.html b/_examples/static-chall/static/index.html
new file mode 100644
index 00000000..bf53541e
--- /dev/null
+++ b/_examples/static-chall/static/index.html
@@ -0,0 +1,10 @@
+
+
+
+
+ Beast static challenge example
+
+
+ This public test page is served by Beast's shared static-content service.
+
+
diff --git a/_examples/web-php-mysql/beast.toml b/_examples/web-php-mysql/beast.toml
index 5e45eb8c..c3e19c22 100644
--- a/_examples/web-php-mysql/beast.toml
+++ b/_examples/web-php-mysql/beast.toml
@@ -1,7 +1,6 @@
[author]
name = "fristonio"
email = "contact+fristonio@sdslabs.co.in"
-ssh_key = "ssh-rsa AAAAB3NzaC1y"
[challenge.metadata]
name = "web-php-mysql"
@@ -27,7 +26,7 @@ web_root = "challenge"
default_port = 10004
[resource]
-cpu_shares = 1024
+cpu_shares = 512
memory_limit = 536870912
pids_limit = 100
cpuslimit = 0.25
diff --git a/_examples/web-php/beast.toml b/_examples/web-php/beast.toml
index eb68611e..32c8cc19 100644
--- a/_examples/web-php/beast.toml
+++ b/_examples/web-php/beast.toml
@@ -1,7 +1,6 @@
[author]
name = "fristonio"
email = "contact+fristonio@sdslabs.co.in"
-ssh_key = "ssh-rsa AAAAB3NzaC1y"
[challenge.metadata]
name = "web-php"
@@ -25,7 +24,7 @@ web_root = "challenge"
default_port = 10002
[resource]
-cpu_shares = 1024
+cpu_shares = 512
memory_limit = 536870912
pids_limit = 100
cpuslimit = 0.25
diff --git a/_examples/xinetd-service/beast.toml b/_examples/xinetd-service/beast.toml
index 3ebbbcfc..ab182a04 100644
--- a/_examples/xinetd-service/beast.toml
+++ b/_examples/xinetd-service/beast.toml
@@ -1,7 +1,6 @@
[author]
name = "contact"
email = "contact@sdslabs.co.in"
-ssh_key = "ssh-rsa AAAAB3NzaC1y"
[challenge.metadata]
name = "xinetd-service"
@@ -9,6 +8,7 @@ flag = "CTF{not_the_flag}"
type = "service"
points = 500
maxAttemptLimit = 10
+preReqs = ["simple", "web-php"]
[[challenge.metadata.hints]]
text = "simple_hint_1"
@@ -18,19 +18,17 @@ points = 10
text = "simple_hint_2"
points = 20
-preReqs = ["simple", "web-php"]
-
[challenge.env]
apt_deps = ["gcc", "socat"]
setup_scripts = ["setup.sh"]
-xinetd_config = "ctf.xinetd"
+xinetd_conf = "ctf.xinetd"
service_path = "pwn"
ports = [10003]
default_port = 10003
[resource]
-cpu_shares = 1024
+cpu_shares = 512
memory_limit = 536870912
pids_limit = 100
cpuslimit = 0.25
diff --git a/api/README.md b/api/README.md
index bee8201b..2204edc7 100644
--- a/api/README.md
+++ b/api/README.md
@@ -1,13 +1,10 @@
-# API interface for beast
+# Beast API documentation
-To build docs for beast api interface, make sure you have `swag` installed
+Regenerate the embedded Swagger contract with the pinned module version:
```bash
-$ go get -u github.com/swaggo/swag/cmd/swag
+go run github.com/swaggo/swag/cmd/swag@v1.16.4 init \
+ --generalInfo main.go --dir api --output api/docs --parseDependency
```
-To build docs make sure you are in `$BEAST_ROOT/api/`
-
-```
-$ swag init
-```
+Commit `api/docs/docs.go`, `swagger.json`, and `swagger.yaml` together with annotation changes. The running HTTPS server publishes the result at `/api/docs/index.html`.
diff --git a/api/admin.go b/api/admin.go
index df0cb6ec..34b75dbf 100644
--- a/api/admin.go
+++ b/api/admin.go
@@ -1,6 +1,7 @@
package api
import (
+ "errors"
"fmt"
"net/http"
"strconv"
@@ -8,6 +9,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/sdslabs/beastv4/core"
"github.com/sdslabs/beastv4/core/database"
+ "gorm.io/gorm"
)
// Ban/Unban/Hide/Unhide a user based on his id and the action provided.
@@ -21,7 +23,7 @@ import (
// @Success 200 {object} api.ChallengeStatusResp
// @Failure 400 {object} api.HTTPPlainResp
// @Failure 500 {object} api.HTTPPlainResp
-// @Router /api/admin/users/:action/:id [post]
+// @Router /api/admin/users/{action}/{id} [post]
func userActionHandler(c *gin.Context) {
action := c.Param("action")
userId := c.Param("id")
@@ -55,6 +57,10 @@ func userActionHandler(c *gin.Context) {
user, err := database.QueryUserById(uint(parsedUserId))
if err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ c.JSON(http.StatusNotFound, HTTPPlainResp{Message: "User not found."})
+ return
+ }
c.JSON(http.StatusInternalServerError, HTTPPlainResp{
Message: "DATABASE ERROR while processing the request.",
})
@@ -69,8 +75,7 @@ func userActionHandler(c *gin.Context) {
return
}
if val, _ := database.IsFrozenScoreSet(); !val {
- leaderboardStale = true
- graphCacheStale = true
+ markLeaderboardCachesStale()
}
c.JSON(http.StatusOK, HTTPPlainResp{
Message: fmt.Sprintf("Successfully %sned the user with id %s", action, userId),
diff --git a/api/assets_test.go b/api/assets_test.go
new file mode 100644
index 00000000..3e5ad75d
--- /dev/null
+++ b/api/assets_test.go
@@ -0,0 +1,19 @@
+package api
+
+import (
+ "testing"
+
+ "github.com/sdslabs/beastv4/core"
+)
+
+func TestDeclaresAssetRequiresExactMetadataEntry(t *testing.T) {
+ assets := "guide.pdf" + core.DELIMITER + "images/logo.png"
+ if !declaresAsset(assets, "images/logo.png") {
+ t.Fatal("declared nested asset was rejected")
+ }
+ for _, requested := range []string{"flag.txt", "../guide.pdf", "logo.png"} {
+ if declaresAsset(assets, requested) {
+ t.Fatalf("undeclared asset %q was accepted", requested)
+ }
+ }
+}
diff --git a/api/auth.go b/api/auth.go
index 2d408eda..bbb15b82 100644
--- a/api/auth.go
+++ b/api/auth.go
@@ -4,17 +4,30 @@ import (
"errors"
"log"
"net/http"
+ "regexp"
"strings"
+ "time"
"github.com/gin-gonic/gin"
"github.com/sdslabs/beastv4/core"
"github.com/sdslabs/beastv4/core/config"
"github.com/sdslabs/beastv4/core/database"
- coreUtils "github.com/sdslabs/beastv4/core/utils"
"github.com/sdslabs/beastv4/pkg/auth"
"gorm.io/gorm"
)
+var contestantUsernamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_.-]{2,11}$`)
+
+func validatePassword(password string) error {
+ if len(password) < 12 || len(password) > 128 {
+ return errors.New("password must contain between 12 and 128 bytes")
+ }
+ if strings.TrimSpace(password) == "" {
+ return errors.New("password cannot contain only whitespace")
+ }
+ return nil
+}
+
// Acts as a middleware to authorize user
// @Summary Handles authorization of user
// @Description Authorizes user by checking if JWT token exists and is valid
@@ -23,16 +36,9 @@ import (
// @Produce json
// @Failure 401 {object} api.HTTPPlainResp
// @Security ApiKeyAuth
-func authorize(c *gin.Context) {
- if config.SkipAuthorization {
- return
- }
-
- authHeader := c.GetHeader("Authorization")
-
- values := strings.Split(authHeader, " ")
-
- if len(values) < 2 || values[0] != "Bearer" {
+func authorizeRoles(c *gin.Context, roles int) {
+ values := strings.Fields(c.GetHeader("Authorization"))
+ if len(values) != 2 || values[0] != "Bearer" {
c.JSON(http.StatusUnauthorized, HTTPPlainResp{
Message: "No Token Provided",
})
@@ -40,8 +46,7 @@ func authorize(c *gin.Context) {
return
}
- err := auth.Authorize(values[1], core.MANAGER|core.ADMIN|core.USER)
-
+ claims, err := auth.AuthorizeClaims(values[1], roles)
if err != nil {
c.JSON(http.StatusUnauthorized, HTTPPlainResp{
Message: err.Error(),
@@ -49,10 +54,34 @@ func authorize(c *gin.Context) {
c.Abort()
return
}
-
+ if database.Db == nil || database.DBMux == nil {
+ c.JSON(http.StatusServiceUnavailable, HTTPPlainResp{Message: "Authorization could not be verified"})
+ c.Abort()
+ return
+ }
+ user, err := database.QueryFirstUserEntry("username", claims.User)
+ if err != nil {
+ status := http.StatusServiceUnavailable
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ status = http.StatusUnauthorized
+ }
+ c.JSON(status, HTTPPlainResp{Message: "Authorization could not be verified"})
+ c.Abort()
+ return
+ }
+ if user.Status != 0 || user.Role != claims.Role {
+ c.JSON(http.StatusUnauthorized, HTTPPlainResp{Message: "Authorization is no longer valid"})
+ c.Abort()
+ return
+ }
+ c.Set("authClaims", claims)
c.Next()
}
+func authorize(c *gin.Context) {
+ authorizeRoles(c, core.MANAGER|core.ADMIN|core.USER)
+}
+
// Acts as a middleware to authorize manager roles
// @Summary Handles authorization of manager roles
// @Description Authorizes authors and admin by checking if JWT token exists and is valid
@@ -62,33 +91,7 @@ func authorize(c *gin.Context) {
// @Failure 401 {object} api.HTTPPlainResp
// @Security ApiKeyAuth
func managerAuthorize(c *gin.Context) {
- if config.SkipAuthorization {
- return
- }
-
- authHeader := c.GetHeader("Authorization")
-
- values := strings.Split(authHeader, " ")
-
- if len(values) < 2 || values[0] != "Bearer" {
- c.JSON(http.StatusUnauthorized, HTTPPlainResp{
- Message: "No Token Provided",
- })
- c.Abort()
- return
- }
-
- err := auth.Authorize(values[1], core.MANAGER|core.ADMIN)
-
- if err != nil {
- c.JSON(http.StatusUnauthorized, HTTPPlainResp{
- Message: err.Error(),
- })
- c.Abort()
- return
- }
-
- c.Next()
+ authorizeRoles(c, core.MANAGER|core.ADMIN)
}
// Acts as a middleware to authorize admin roles
@@ -100,32 +103,24 @@ func managerAuthorize(c *gin.Context) {
// @Failure 401 {object} api.HTTPPlainResp
// @Security ApiKeyAuth
func adminAuthorize(c *gin.Context) {
- if config.SkipAuthorization {
- return
- }
-
- authHeader := c.GetHeader("Authorization")
-
- values := strings.Split(authHeader, " ")
+ authorizeRoles(c, core.ADMIN)
+}
- if len(values) < 2 || values[0] != "Bearer" {
- c.JSON(http.StatusUnauthorized, HTTPPlainResp{
- Message: "No Token Provided",
- })
- c.Abort()
+func resetPasswordAuthorize(c *gin.Context) {
+ values := strings.Fields(c.GetHeader("Authorization"))
+ if len(values) != 2 || values[0] != "Bearer" {
+ c.AbortWithStatusJSON(http.StatusUnauthorized, HTTPPlainResp{Message: "No Token Provided"})
return
}
-
- err := auth.Authorize(values[1], core.ADMIN)
-
+ claims, err := auth.AuthorizeClaims(values[1], core.MANAGER|core.ADMIN|core.USER)
if err != nil {
- c.JSON(http.StatusUnauthorized, HTTPPlainResp{
- Message: err.Error(),
- })
- c.Abort()
+ claims, err = auth.AuthorizePasswordResetClaims(values[1])
+ }
+ if err != nil {
+ c.AbortWithStatusJSON(http.StatusUnauthorized, HTTPPlainResp{Message: "Invalid reset token"})
return
}
-
+ c.Set("authClaims", claims)
c.Next()
}
@@ -147,27 +142,28 @@ func login(c *gin.Context) {
password := c.PostForm("password")
username = strings.TrimSpace(strings.ToLower(username))
- password = strings.TrimSpace(password)
if username == "" || password == "" {
c.JSON(http.StatusBadRequest, HTTPPlainResp{
Message: "Username and password can not be empty",
})
+ return
}
-
- userEntry, err := database.QueryFirstUserEntry("username", username)
-
- if err != nil {
- c.JSON(http.StatusBadRequest, HTTPPlainResp{
- Message: err.Error(),
- })
+ if !enforceLoginRateLimit(c, username) {
return
}
- if userEntry.Status == 1 {
- c.JSON(http.StatusForbidden, HTTPPlainResp{
- Message: "The user has been banned from this competition. Please contact competition admin for more information",
- })
+ userEntry, err := database.QueryFirstUserEntry("username", username)
+ if err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ _, _ = auth.Authenticate(username, password, auth.AuthModel{
+ Salt: make([]byte, 16),
+ Password: make([]byte, core.HASH_LENGTH),
+ })
+ c.JSON(http.StatusUnauthorized, HTTPPlainResp{Message: "The username or password is invalid"})
+ return
+ }
+ c.JSON(http.StatusServiceUnavailable, HTTPPlainResp{Message: "Authentication service unavailable"})
return
}
@@ -179,6 +175,12 @@ func login(c *gin.Context) {
})
return
}
+ if userEntry.Status == 1 {
+ c.JSON(http.StatusForbidden, HTTPPlainResp{
+ Message: "The user has been banned from this competition. Please contact competition admin for more information",
+ })
+ return
+ }
c.JSON(http.StatusOK, HTTPAuthorizeResp{
Token: jwt,
@@ -197,7 +199,6 @@ func login(c *gin.Context) {
// @Param username formData string true "Username"
// @Param password formData string true "Password"
// @Param email formData string true "User's email id"
-// @Param ssh-key formData string false "User's ssh-key"
// @Success 200 {object} api.HTTPPlainResp
// @Failure 400 {object} api.HTTPPlainResp
// @Failure 406 {object} api.HTTPPlainResp
@@ -207,13 +208,10 @@ func register(c *gin.Context) {
username := c.PostForm("username")
password := c.PostForm("password")
email := c.PostForm("email")
- sshKey := c.PostForm("ssh-key")
name = strings.TrimSpace(name)
username = strings.TrimSpace(strings.ToLower(username))
- password = strings.TrimSpace(password)
email = strings.TrimSpace(strings.ToLower(email))
- sshKey = strings.TrimSpace(sshKey)
if username == "" || password == "" || email == "" {
@@ -223,52 +221,52 @@ func register(c *gin.Context) {
return
}
- if len(username) > 12 {
+ if !contestantUsernamePattern.MatchString(username) {
c.JSON(http.StatusBadRequest, HTTPErrorResp{
- Error: "Username cannot be greater than 12 characters",
+ Error: "Username must be 3-12 lowercase letters, digits, dots, underscores, or hyphens",
})
return
}
-
- userEntry := database.User{
- Name: name,
- AuthModel: auth.CreateModel(username, password, core.USER_ROLES["contestant"]),
- Email: email,
- SshKey: sshKey,
+ if err := validatePassword(password); err != nil {
+ c.JSON(http.StatusBadRequest, HTTPErrorResp{Error: err.Error()})
+ return
+ }
+ if canonical, err := canonicalMailbox(email); err != nil || canonical != email {
+ c.JSON(http.StatusBadRequest, HTTPErrorResp{Error: "A valid email address is required"})
+ return
}
- // skip otp verif if -n flag is enabled
- if !config.SkipAuthorization {
- smtpHost := config.Cfg.MailConfig.SMTPHost
- smtpPort := config.Cfg.MailConfig.SMTPPort
-
- if smtpHost == "" || smtpPort == "" {
- log.Printf("WARNING: %s", "SMTP not configured")
+ smtpHost := config.Cfg.MailConfig.SMTPHost
+ smtpPort := config.Cfg.MailConfig.SMTPPort
+ if smtpHost == "" || smtpPort == "" {
+ c.JSON(http.StatusServiceUnavailable, HTTPErrorResp{Error: "SMTP not configured"})
+ return
+ }
+ otpEntry, err := database.QueryOTPEntry(email)
+ if err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ c.JSON(http.StatusUnauthorized, HTTPErrorResp{Error: "OTP not found, email not verified"})
} else {
- otpEntry, err := database.QueryOTPEntry(email)
- if err != nil {
- if errors.Is(err, gorm.ErrRecordNotFound) {
- c.JSON(http.StatusUnauthorized, HTTPErrorResp{
- Error: "OTP not found, email not verified",
- })
- return
- } else {
- log.Println("Failed to query OTP:", err)
- c.JSON(http.StatusInternalServerError, HTTPErrorResp{
- Error: "Failed to send OTP",
- })
- return
- }
- }
- if !otpEntry.Verified {
- c.JSON(http.StatusNotAcceptable, HTTPErrorResp{
- Error: "Email not verified, cannot register user",
- })
- return
- }
+ log.Println("Failed to query OTP:", err)
+ c.JSON(http.StatusInternalServerError, HTTPErrorResp{Error: "Failed to verify OTP"})
}
+ return
+ }
+ if !otpEntry.Verified || otpEntry.Purpose != otpPurposeRegistration || time.Now().After(otpEntry.Expiry) {
+ c.JSON(http.StatusNotAcceptable, HTTPErrorResp{Error: "Email not verified, cannot register user"})
+ return
+ }
+ authModel, err := auth.CreateModel(username, password, core.USER_ROLES["contestant"])
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, HTTPErrorResp{Error: "Failed to secure user credentials"})
+ return
}
- err := database.CreateUserEntry(&userEntry)
+ userEntry := database.User{
+ Name: name,
+ AuthModel: authModel,
+ Email: email,
+ }
+ err = database.CreateUserEntry(&userEntry)
if err != nil {
c.JSON(http.StatusNotAcceptable, HTTPErrorResp{
@@ -276,13 +274,12 @@ func register(c *gin.Context) {
})
return
}
-
- if len(adminLeaderboardCache) < core.LEADERBOARD_SIZE {
- leaderboardStale = true
- graphCacheStale = true
- adminLeaderboardStale = true
+ if err := database.DeleteOTPEntry(email); err != nil {
+ log.Printf("Failed to consume registration OTP: %v", err)
}
+ markLeaderboardCachesStale()
+
c.JSON(http.StatusOK, HTTPPlainResp{
Message: "User created successfully",
})
@@ -300,24 +297,40 @@ func register(c *gin.Context) {
// @Router /auth/reset-password [post]
func resetPasswordHandler(c *gin.Context) {
newPass := c.PostForm("new_pass")
- newPass = strings.TrimSpace(newPass)
+ if err := validatePassword(newPass); err != nil {
+ c.JSON(http.StatusBadRequest, HTTPPlainResp{Message: err.Error()})
+ return
+ }
- username, err := coreUtils.GetUser(c.GetHeader("Authorization"))
- if err != nil {
+ claimsValue, exists := c.Get("authClaims")
+ claims, ok := claimsValue.(*auth.CustomClaims)
+ if !exists || !ok {
c.JSON(http.StatusUnauthorized, HTTPPlainResp{
Message: "Unauthorized user",
})
return
}
+ username := claims.User
user, err := database.QueryFirstUserEntry("username", username)
if err != nil {
c.JSON(http.StatusUnauthorized, HTTPPlainResp{
Message: "Unauthorized user",
})
+ return
}
- authModel := auth.CreateModel(username, newPass, user.Role)
+ authModel, err := auth.CreateModel(username, newPass, user.Role)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, HTTPPlainResp{Message: "Failed to secure user credentials"})
+ return
+ }
+ if claims.TokenUse == auth.PasswordResetTokenUse {
+ if err := database.ConsumeVerifiedOTP(user.Email, otpPurposePasswordReset, time.Now()); err != nil {
+ c.JSON(http.StatusUnauthorized, HTTPPlainResp{Message: "Password reset grant is invalid or already used"})
+ return
+ }
+ }
err = database.UpdateUser(&user, map[string]interface{}{"Password": authModel.Password, "Salt": authModel.Salt})
if err != nil {
diff --git a/api/auth_middleware_test.go b/api/auth_middleware_test.go
new file mode 100644
index 00000000..21bc4642
--- /dev/null
+++ b/api/auth_middleware_test.go
@@ -0,0 +1,44 @@
+package api
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/sdslabs/beastv4/core"
+ "github.com/sdslabs/beastv4/pkg/auth"
+)
+
+func TestManagerAuthorizationRequiresVerifiedToken(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ auth.Init(1, 32, 60, "issuer", "secret", []string{"author"}, []string{"admin"}, []string{"contestant"})
+
+ router := gin.New()
+ router.GET("/protected", managerAuthorize, func(c *gin.Context) {
+ claims, exists := c.Get("authClaims")
+ if !exists || claims.(*auth.CustomClaims).User != "alice" {
+ c.Status(http.StatusInternalServerError)
+ return
+ }
+ c.Status(http.StatusNoContent)
+ })
+
+ unauthorized := httptest.NewRecorder()
+ router.ServeHTTP(unauthorized, httptest.NewRequest(http.MethodGet, "/protected", nil))
+ if unauthorized.Code != http.StatusUnauthorized {
+ t.Fatalf("unauthorized status = %d", unauthorized.Code)
+ }
+
+ token, err := auth.GenerateJWT(auth.AuthModel{Username: "alice", Role: core.USER_ROLES["author"]})
+ if err != nil {
+ t.Fatal(err)
+ }
+ request := httptest.NewRequest(http.MethodGet, "/protected", nil)
+ request.Header.Set("Authorization", "Bearer "+token)
+ authorized := httptest.NewRecorder()
+ router.ServeHTTP(authorized, request)
+ if authorized.Code != http.StatusServiceUnavailable {
+ t.Fatalf("unbacked token status = %d, body = %s", authorized.Code, authorized.Body.String())
+ }
+}
diff --git a/api/auth_test.go b/api/auth_test.go
new file mode 100644
index 00000000..c952e33c
--- /dev/null
+++ b/api/auth_test.go
@@ -0,0 +1,19 @@
+package api
+
+import "testing"
+
+func TestCredentialValidation(t *testing.T) {
+ for _, password := range []string{"short", " ", string(make([]byte, 129))} {
+ if err := validatePassword(password); err == nil {
+ t.Fatalf("expected password %q to be rejected", password)
+ }
+ }
+ if err := validatePassword("correct horse battery staple"); err != nil {
+ t.Fatal(err)
+ }
+ for _, username := range []string{"ab", "UPPER", "../escape", "space name"} {
+ if contestantUsernamePattern.MatchString(username) {
+ t.Fatalf("expected username %q to be rejected", username)
+ }
+ }
+}
diff --git a/api/challenge_access_test.go b/api/challenge_access_test.go
new file mode 100644
index 00000000..01db9a65
--- /dev/null
+++ b/api/challenge_access_test.go
@@ -0,0 +1,27 @@
+package api
+
+import (
+ "testing"
+
+ "github.com/sdslabs/beastv4/core"
+ "github.com/sdslabs/beastv4/core/database"
+ "github.com/sdslabs/beastv4/pkg/auth"
+)
+
+func TestCanViewChallengeSecretsForIntrinsicRoles(t *testing.T) {
+ challenge := database.Challenge{AuthorID: 7}
+ challenge.ID = 11
+ admin := database.User{AuthModel: auth.AuthModel{Role: core.USER_ROLES["admin"]}}
+ if allowed, err := canViewChallengeSecrets(&admin, &challenge); err != nil || !allowed {
+ t.Fatalf("admin access = %v, %v", allowed, err)
+ }
+ owner := database.User{AuthModel: auth.AuthModel{Role: core.USER_ROLES["author"]}}
+ owner.ID = 7
+ if allowed, err := canViewChallengeSecrets(&owner, &challenge); err != nil || !allowed {
+ t.Fatalf("owner access = %v, %v", allowed, err)
+ }
+ contestant := database.User{AuthModel: auth.AuthModel{Role: core.USER_ROLES["contestant"]}}
+ if allowed, err := canViewChallengeSecrets(&contestant, &challenge); err != nil || allowed {
+ t.Fatalf("contestant access = %v, %v", allowed, err)
+ }
+}
diff --git a/api/challenge_authorization.go b/api/challenge_authorization.go
new file mode 100644
index 00000000..ab1d13a8
--- /dev/null
+++ b/api/challenge_authorization.go
@@ -0,0 +1,90 @@
+package api
+
+import (
+ "errors"
+ "net/http"
+ "path/filepath"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+ "github.com/sdslabs/beastv4/core"
+ challengeConfig "github.com/sdslabs/beastv4/core/config"
+ "github.com/sdslabs/beastv4/core/database"
+ coreUtils "github.com/sdslabs/beastv4/core/utils"
+ "github.com/sdslabs/beastv4/pkg/auth"
+ "gorm.io/gorm"
+)
+
+func authenticatedManager(c *gin.Context) (database.User, bool) {
+ claimsValue, exists := c.Get("authClaims")
+ claims, valid := claimsValue.(*auth.CustomClaims)
+ if !exists || !valid {
+ c.AbortWithStatusJSON(http.StatusUnauthorized, HTTPErrorResp{Error: "verified authentication claims are required"})
+ return database.User{}, false
+ }
+ user, err := database.QueryFirstUserEntry("username", claims.User)
+ if err != nil || user.ID == 0 || user.Status != 0 {
+ c.AbortWithStatusJSON(http.StatusForbidden, HTTPErrorResp{Error: "challenge management access denied"})
+ return database.User{}, false
+ }
+ return user, true
+}
+
+func userOwnsChallengeConfig(user database.User, configuration challengeConfig.BeastChallengeConfig) bool {
+ if user.Role == core.USER_ROLES["admin"] {
+ return true
+ }
+ if strings.EqualFold(user.Email, configuration.Author.Email) {
+ return true
+ }
+ for _, maintainer := range configuration.Maintainers {
+ if strings.EqualFold(user.Email, maintainer.Email) {
+ return true
+ }
+ }
+ return false
+}
+
+func authorizeChallengeManagement(c *gin.Context, challengeName string, allowRemoteConfig bool) bool {
+ user, ok := authenticatedManager(c)
+ if !ok {
+ return false
+ }
+ challenge, err := database.QueryFirstChallengeEntry("name", challengeName)
+ if err == nil {
+ maintainer, relationErr := database.IsChallengeMaintainer(user.ID, challenge.ID)
+ if relationErr != nil {
+ c.AbortWithStatusJSON(http.StatusInternalServerError, HTTPErrorResp{Error: "failed to authorize challenge access"})
+ return false
+ }
+ if !userCanExecChallenge(user, challenge, maintainer) {
+ c.AbortWithStatusJSON(http.StatusForbidden, HTTPErrorResp{Error: "challenge management access denied"})
+ return false
+ }
+ return true
+ }
+ if !errors.Is(err, gorm.ErrRecordNotFound) {
+ c.AbortWithStatusJSON(http.StatusInternalServerError, HTTPErrorResp{Error: "failed to query challenge"})
+ return false
+ }
+ if !allowRemoteConfig {
+ c.AbortWithStatusJSON(http.StatusNotFound, HTTPErrorResp{Error: "challenge not found"})
+ return false
+ }
+
+ challengeDir := coreUtils.GetChallengeDir(challengeName)
+ if challengeDir == "" {
+ c.AbortWithStatusJSON(http.StatusNotFound, HTTPErrorResp{Error: "challenge not found"})
+ return false
+ }
+ configuration, loadErr := challengeConfig.LoadChallengeConfig(filepath.Join(challengeDir, core.CHALLENGE_CONFIG_FILE_NAME))
+ if loadErr != nil {
+ c.AbortWithStatusJSON(http.StatusBadRequest, HTTPErrorResp{Error: "challenge configuration is invalid"})
+ return false
+ }
+ if !userOwnsChallengeConfig(user, configuration) {
+ c.AbortWithStatusJSON(http.StatusForbidden, HTTPErrorResp{Error: "challenge management access denied"})
+ return false
+ }
+ return true
+}
diff --git a/api/challenge_authorization_test.go b/api/challenge_authorization_test.go
new file mode 100644
index 00000000..1141ed99
--- /dev/null
+++ b/api/challenge_authorization_test.go
@@ -0,0 +1,34 @@
+package api
+
+import (
+ "testing"
+
+ "github.com/sdslabs/beastv4/core"
+ challengeConfig "github.com/sdslabs/beastv4/core/config"
+ "github.com/sdslabs/beastv4/core/database"
+ "github.com/sdslabs/beastv4/pkg/auth"
+)
+
+func TestUserOwnsChallengeConfig(t *testing.T) {
+ configuration := challengeConfig.BeastChallengeConfig{
+ Author: challengeConfig.Author{Email: "author@example.com"},
+ Maintainers: []challengeConfig.Author{{Email: "maintainer@example.com"}},
+ }
+ tests := []struct {
+ name string
+ user database.User
+ want bool
+ }{
+ {name: "author", user: database.User{Email: "AUTHOR@example.com"}, want: true},
+ {name: "maintainer", user: database.User{Email: "maintainer@example.com"}, want: true},
+ {name: "unrelated", user: database.User{Email: "other@example.com"}, want: false},
+ {name: "admin", user: database.User{Email: "other@example.com", AuthModel: auth.AuthModel{Role: core.USER_ROLES["admin"]}}, want: true},
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ if got := userOwnsChallengeConfig(test.user, configuration); got != test.want {
+ t.Fatalf("userOwnsChallengeConfig() = %t, want %t", got, test.want)
+ }
+ })
+ }
+}
diff --git a/api/config.go b/api/config.go
index 8d786025..63004453 100644
--- a/api/config.go
+++ b/api/config.go
@@ -1,136 +1,23 @@
package api
import (
+ "errors"
"fmt"
"net/http"
- "os"
+ "net/url"
"path/filepath"
+ "regexp"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/sdslabs/beastv4/core"
- "github.com/sdslabs/beastv4/core/config"
"github.com/sdslabs/beastv4/core/database"
log "github.com/sirupsen/logrus"
+ "gorm.io/gorm"
)
-// This reloads the beast global configuration
-// @Summary Reloads any changes in beast global configuration, located at ~/.beast/config.toml.
-// @Description Populates beast gobal config map by reparsing the config file $HOME/.beast/config.toml.
-// @Tags config
-// @Accept json
-// @Produce json
-// @Param Authorization header string true "Bearer"
-// @Success 200 {object} api.HTTPPlainResp
-// @Failure 400 {object} api.HTTPPlainResp
-// @Router /api/config/reload/ [patch]
-func reloadBeastConfig(c *gin.Context) {
- err := config.ReloadBeastConfig()
- if err != nil {
- log.Errorf("%s", err)
- c.JSON(http.StatusBadRequest, HTTPPlainResp{
- Message: err.Error(),
- })
- return
- }
-
- c.JSON(http.StatusOK, HTTPPlainResp{
- Message: "CONFIG RELOAD SUCCESSFUL",
- })
-}
-
-// This updates competition info in the beast global configuration
-// @Summary Updates competition info in the beast global configuration, located at ~/.beast/config.toml.
-// @Description Populates beast gobal config map by reparsing the config file $HOME/.beast/config.toml.
-// @Tags config
-// @Accept json
-// @Produce json
-// @Param name formData string true "Competition Name"
-// @Param about formData string true "Some information about competition"
-// @Param prizes formData string false "Competitions Prizes for the winners"
-// @Param starting_time formData string true "Competition's starting time"
-// @Param ending_time formData string true "Competition's ending time"
-// @Param timezone formData string true "Competition's timezone"
-// @Param logo formData file false "Competition's logo"
-// @Success 200 {object} api.HTTPPlainResp
-// @Failure 400 {object} api.HTTPPlainResp
-// @Failure 500 {object} api.HTTPErrorResp
-// @Router /api/config/competition-info [post]
-func updateCompetitionInfoHandler(c *gin.Context) {
- var logoFilePath string
-
- name := c.PostForm("name")
- about := c.PostForm("about")
- prizes := c.PostForm("prizes")
- starting_time := c.PostForm("starting_time")
- ending_time := c.PostForm("ending_time")
- timezone := c.PostForm("timezone")
- logo, err := c.FormFile("logo")
-
- // The file cannot be received.
- if err != nil {
- log.Info("No file recieved from the user")
- } else {
- logoFilePath = filepath.Join(
- core.BEAST_GLOBAL_DIR,
- core.BEAST_ASSETS_DIR,
- core.BEAST_LOGO_DIR,
- logo.Filename,
- )
-
- competitionInfo, err := config.GetCompetitionInfo()
- if err != nil {
- log.Info("Unable to load previous config")
- c.JSON(http.StatusInternalServerError, HTTPErrorResp{
- Error: fmt.Sprintf("Unable to load previous config: %s", err),
- })
- return
- }
-
- // Delete previously uploaded logo file
- if competitionInfo.LogoURL != "" {
- if err := os.Remove(competitionInfo.LogoURL); err != nil {
- log.Info("Unable to delete previous logo file")
- c.JSON(http.StatusInternalServerError, HTTPErrorResp{
- Error: fmt.Sprintf("Unable to delete previous logo file: %s", err),
- })
- return
- }
- }
-
- // The file is received, save it
- if err := c.SaveUploadedFile(logo, logoFilePath); err != nil {
- c.JSON(http.StatusInternalServerError, HTTPErrorResp{
- Error: fmt.Sprintf("Unable to save file: %s", err),
- })
- return
- }
- }
-
- configInfo := config.CompetitionInfo{
- Name: name,
- About: about,
- Prizes: prizes,
- StartingTime: starting_time,
- EndingTime: ending_time,
- TimeZone: timezone,
- LogoURL: logoFilePath,
- }
-
- err = config.UpdateCompetitionInfo(&configInfo)
- if err != nil {
- c.JSON(http.StatusBadRequest, HTTPPlainResp{
- Message: err.Error(),
- })
- return
- }
-
- c.JSON(http.StatusOK, HTTPPlainResp{
- Message: "Competition information updated successfully",
- })
- return
-}
+var challengeTagPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,31}$`)
// This updates challenge info in the respective challenge configuration
// @Summary Updates challenge info in the database, located at ~/.beast/beast.db.
@@ -139,124 +26,186 @@ func updateCompetitionInfoHandler(c *gin.Context) {
// @Accept json
// @Produce json
// @Param name formData string true "Challenge Name"
-// @Param hints formData string true "Challenge's hints"
// @Param desc formData string false "Challenge's description"
-// @Param points formData string true "Challenge's points"
-// @Param flag formData string true "Challenge's flag"
-// @Param tags formData string true "Challenge's tags"
-// @Param ports formData file false "Challenge's ports"
+// @Param points formData string false "Challenge's points"
+// @Param flag formData string false "Challenge's flag"
+// @Param tags formData string false "Challenge's tags"
+// @Param ports formData string false "Challenge's ports"
// @Success 200 {object} api.HTTPPlainResp
// @Failure 400 {object} api.HTTPPlainResp
// @Failure 500 {object} api.HTTPErrorResp
// @Router /api/config/challenge-info [post]
func updateChallengeInfoHandler(c *gin.Context) {
- name, exist := c.GetPostForm("name")
- if !exist {
- c.JSON(http.StatusBadRequest, HTTPPlainResp{
- Message: fmt.Sprintf("Can't edit challenge without challenge name"),
- })
+ name := strings.TrimSpace(c.PostForm("name"))
+ if name == "" {
+ c.JSON(http.StatusBadRequest, HTTPPlainResp{Message: "Can't edit challenge without challenge name"})
return
}
- configInfo := map[string]interface{}{
- "Name": name,
+ chall, err := database.QueryFirstChallengeEntry("name", name)
+ if err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ c.JSON(http.StatusNotFound, HTTPErrorResp{Error: "Challenge not found"})
+ return
+ }
+ log.Errorf("database error while querying challenge %s: %v", name, err)
+ c.JSON(http.StatusInternalServerError, HTTPErrorResp{Error: "Database error while querying challenge"})
+ return
}
- desc, exist := c.GetPostForm("desc")
- if exist {
- configInfo["Description"] = desc
+ updates, ports, tags, err := parseChallengeUpdate(c, chall)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, HTTPPlainResp{Message: err.Error()})
+ return
}
-
- points, exist := c.GetPostForm("points")
- if exist {
- configInfo["Points"] = points
+ if err := database.UpdateChallengeConfiguration(chall.ID, updates, ports, tags); err != nil {
+ log.Errorf("failed to update challenge %s: %v", name, err)
+ c.JSON(http.StatusInternalServerError, HTTPPlainResp{Message: "Failed to update challenge"})
+ return
}
- flag, exist := c.GetPostForm("flag")
- if exist {
- configInfo["Flag"] = flag
- }
+ c.JSON(http.StatusOK, HTTPPlainResp{Message: fmt.Sprintf("Successfully updated challenge: %s", name)})
+}
- assets, exist := c.GetPostForm("assets")
- if exist {
- configInfo["Assets"] = assets
+func parseChallengeUpdate(c *gin.Context, challenge database.Challenge) (map[string]interface{}, *[]uint32, *[]string, error) {
+ updates := make(map[string]interface{})
+ if description, exists := c.GetPostForm("desc"); exists {
+ if len(description) > 64<<10 {
+ return nil, nil, nil, errors.New("description exceeds 64 KiB")
+ }
+ updates["description"] = description
}
-
- additionalLinks, exist := c.GetPostForm("additionalLinks")
- if exist {
- configInfo["AdditionalLinks"] = additionalLinks
+ if pointsValue, exists := c.GetPostForm("points"); exists {
+ points, err := strconv.ParseUint(strings.TrimSpace(pointsValue), 10, 32)
+ if err != nil {
+ return nil, nil, nil, errors.New("points must be an unsigned 32-bit integer")
+ }
+ if challenge.MaxPoints > 0 && uint(points) > challenge.MaxPoints || uint(points) < challenge.MinPoints {
+ return nil, nil, nil, errors.New("points are outside the configured scoring range")
+ }
+ updates["points"] = uint(points)
+ }
+ if flag, exists := c.GetPostForm("flag"); exists {
+ if (!challenge.DynamicFlag && flag == "") || len(flag) > 4096 {
+ return nil, nil, nil, errors.New("flag must contain between 1 and 4096 bytes")
+ }
+ updates["flag"] = flag
+ }
+ if assets, exists := c.GetPostForm("assets"); exists {
+ if err := validateAssetList(assets); err != nil {
+ return nil, nil, nil, err
+ }
+ updates["assets"] = assets
+ }
+ if links, exists := c.GetPostForm("additionalLinks"); exists {
+ if err := validateAdditionalLinks(links); err != nil {
+ return nil, nil, nil, err
+ }
+ updates["additional_links"] = links
}
- log.Debug(fmt.Sprintf("Starting to update the challenge : %s", name))
- chall, err := database.QueryFirstChallengeEntry("name", name)
+ ports, err := parsePorts(c)
if err != nil {
- log.Errorf("DB_ACCESS_ERROR : %s", err.Error())
- c.JSON(http.StatusInternalServerError, HTTPErrorResp{
- Error: fmt.Sprintf("DB_ACCESS_ERROR : %s", err.Error()),
- })
- return
+ return nil, nil, nil, err
}
-
- // Update challenge
- if e := database.UpdateChallenge(&chall, configInfo); e != nil {
- c.JSON(http.StatusBadRequest, HTTPPlainResp{
- Message: fmt.Sprintf("Error while updating challenge info: %s", e.Error()),
- })
- return
+ tags, err := parseTags(c)
+ if err != nil {
+ return nil, nil, nil, err
}
+ return updates, ports, tags, nil
+}
- // Update ports
- ports, exist := c.GetPostForm("ports")
- if exist {
- if err := database.UpdatePorts(&chall); err != nil {
- c.JSON(http.StatusBadRequest, HTTPPlainResp{
- Message: fmt.Sprintf("Error: %s", err.Error()),
- })
- return
+func parsePorts(c *gin.Context) (*[]uint32, error) {
+ value, exists := c.GetPostForm("ports")
+ if !exists {
+ return nil, nil
+ }
+ ports := make([]uint32, 0)
+ seen := make(map[uint32]struct{})
+ for _, rawPort := range strings.Split(value, ",") {
+ if strings.TrimSpace(rawPort) == "" {
+ continue
}
- ports = strings.ReplaceAll(ports, " ", "")
- portsArray := strings.Split(ports, ",")
- for _, port := range portsArray {
- u64, err := strconv.ParseUint(port, 10, 32)
- if err != nil {
- c.JSON(http.StatusBadRequest, HTTPPlainResp{
- Message: fmt.Sprintf("Error: %s", err.Error()),
- })
- return
- }
-
- newPort := database.Port{ChallengeID: chall.ID, PortNo: uint32(u64)}
- _, err = database.PortEntryGetOrCreate(&newPort)
- if err != nil {
- c.JSON(http.StatusBadRequest, HTTPPlainResp{
- Message: fmt.Sprintf("Error while updating challenge ports: %s", err.Error()),
- })
- return
- }
+ port, err := strconv.ParseUint(strings.TrimSpace(rawPort), 10, 16)
+ if err != nil || port == 0 {
+ return nil, errors.New("ports must be comma-separated integers from 1 to 65535")
+ }
+ portNumber := uint32(port)
+ if _, duplicate := seen[portNumber]; duplicate {
+ return nil, fmt.Errorf("duplicate port %d", portNumber)
+ }
+ seen[portNumber] = struct{}{}
+ ports = append(ports, portNumber)
+ if len(ports) > 256 {
+ return nil, errors.New("at most 256 ports are allowed")
}
}
+ return &ports, nil
+}
- // Update Tags
- tags, exist := c.GetPostForm("tags")
- if exist {
- tagsArray := strings.Split(tags, core.DELIMITER)
- tagArr := make([]*database.Tag, len(tagsArray))
-
- for index, tag := range tagsArray {
- tagArr[index] = &database.Tag{
- TagName: tag,
- }
+func parseTags(c *gin.Context) (*[]string, error) {
+ value, exists := c.GetPostForm("tags")
+ if !exists {
+ return nil, nil
+ }
+ tags := make([]string, 0)
+ seen := make(map[string]struct{})
+ for _, rawTag := range strings.Split(value, core.DELIMITER) {
+ tag := strings.TrimSpace(rawTag)
+ if tag == "" {
+ continue
+ }
+ if !challengeTagPattern.MatchString(tag) {
+ return nil, fmt.Errorf("invalid tag %q", tag)
+ }
+ if _, duplicate := seen[tag]; duplicate {
+ continue
+ }
+ seen[tag] = struct{}{}
+ tags = append(tags, tag)
+ if len(tags) > 32 {
+ return nil, errors.New("at most 32 tags are allowed")
}
+ }
+ return &tags, nil
+}
- if err = database.UpdateTags(tagArr, &chall); err != nil {
- c.JSON(http.StatusBadRequest, HTTPPlainResp{
- Message: fmt.Sprintf("Error while updating tags: %s", err.Error()),
- })
+func validateAssetList(value string) error {
+ assets := strings.Split(value, core.DELIMITER)
+ if len(assets) > 128 {
+ return errors.New("at most 128 assets are allowed")
+ }
+ for _, asset := range assets {
+ if asset == "" {
+ continue
+ }
+ if len(asset) > 255 {
+ return errors.New("asset path exceeds 255 bytes")
+ }
+ cleaned := filepath.Clean(asset)
+ if filepath.IsAbs(asset) || cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) || strings.Contains(asset, `\`) {
+ return fmt.Errorf("invalid asset path %q", asset)
}
}
+ return nil
+}
- c.JSON(http.StatusOK, HTTPPlainResp{
- Message: fmt.Sprintf("Succesfully updated challenge: %s", name),
- })
- return
+func validateAdditionalLinks(value string) error {
+ links := strings.Split(value, core.DELIMITER)
+ if len(links) > 32 {
+ return errors.New("at most 32 additional links are allowed")
+ }
+ for _, link := range links {
+ if link == "" {
+ continue
+ }
+ if len(link) > 2048 {
+ return errors.New("additional link exceeds 2048 bytes")
+ }
+ parsed, err := url.ParseRequestURI(link)
+ if err != nil || (parsed.Scheme != "https" && parsed.Scheme != "http") || parsed.Host == "" || parsed.User != nil {
+ return fmt.Errorf("invalid additional link %q", link)
+ }
+ }
+ return nil
}
diff --git a/api/config_test.go b/api/config_test.go
new file mode 100644
index 00000000..3ff22cac
--- /dev/null
+++ b/api/config_test.go
@@ -0,0 +1,51 @@
+package api
+
+import (
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/sdslabs/beastv4/core"
+ "github.com/sdslabs/beastv4/core/database"
+)
+
+func challengeUpdateContext(values url.Values) *gin.Context {
+ request := httptest.NewRequest("POST", "/", strings.NewReader(values.Encode()))
+ request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ context, _ := gin.CreateTestContext(httptest.NewRecorder())
+ context.Request = request
+ return context
+}
+
+func TestParseChallengeUpdateRejectsInvalidValues(t *testing.T) {
+ challenge := database.Challenge{MaxPoints: 500, MinPoints: 100}
+ tests := []url.Values{
+ {"points": {"99"}},
+ {"ports": {"80,80"}},
+ {"tags": {"web" + core.DELIMITER + "../admin"}},
+ {"assets": {"../flag"}},
+ {"additionalLinks": {"file:///etc/passwd"}},
+ }
+ for _, values := range tests {
+ if _, _, _, err := parseChallengeUpdate(challengeUpdateContext(values), challenge); err == nil {
+ t.Fatalf("expected invalid update to fail: %v", values)
+ }
+ }
+}
+
+func TestParseChallengeUpdateProducesTypedValues(t *testing.T) {
+ values := url.Values{
+ "points": {"250"},
+ "ports": {"8080, 8443"},
+ "tags": {"web" + core.DELIMITER + "beginner"},
+ }
+ updates, ports, tags, err := parseChallengeUpdate(challengeUpdateContext(values), database.Challenge{MaxPoints: 500, MinPoints: 100})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if updates["points"] != uint(250) || len(*ports) != 2 || len(*tags) != 2 {
+ t.Fatalf("unexpected parsed update: updates=%v ports=%v tags=%v", updates, ports, tags)
+ }
+}
diff --git a/api/docs/docs.go b/api/docs/docs.go
index 2643c42a..b8fd3e15 100644
--- a/api/docs/docs.go
+++ b/api/docs/docs.go
@@ -1,40 +1,31 @@
-// GENERATED BY THE COMMAND ABOVE; DO NOT EDIT
-// This file was generated by swaggo/swag
-
+// Package docs Code generated by swaggo/swag. DO NOT EDIT
package docs
-import (
- "bytes"
- "encoding/json"
- "strings"
-
- "github.com/alecthomas/template"
- "github.com/swaggo/swag"
-)
+import "github.com/swaggo/swag"
-var doc = `{
+const docTemplate = `{
"schemes": {{ marshal .Schemes }},
"swagger": "2.0",
"info": {
- "description": "{{.Description}}",
+ "description": "{{escape .Description}}",
"title": "{{.Title}}",
"contact": {
"name": "SDSLabs",
"url": "https://chat.sdslabs.co",
- "email": "contact.sdslabs.co.in"
+ "email": "contact@sdslabs.co.in"
},
"license": {
"name": "Apache 2.0",
- "url": "http://www.apache.org/licenses/LICENSE-2.0.html"
+ "url": "https://www.apache.org/licenses/LICENSE-2.0.html"
},
"version": "{{.Version}}"
},
"host": "{{.Host}}",
"basePath": "{{.BasePath}}",
"paths": {
- "/api/admin/statistics": {
- "get": {
- "description": "returns various information about the competition which are used to control competition",
+ "/api/admin/freezeLeaderboard": {
+ "post": {
+ "description": "freezes the user leaderboard on demand.",
"consumes": [
"application/json"
],
@@ -44,7 +35,7 @@ var doc = `{
"tags": [
"info"
],
- "summary": "returns competition info",
+ "summary": "Freeze user leaderboard",
"parameters": [
{
"type": "string",
@@ -58,7 +49,7 @@ var doc = `{
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/api.CompetitionInfoResp"
+ "$ref": "#/definitions/api.UserResp"
}
},
"400": {
@@ -66,54 +57,69 @@ var doc = `{
"schema": {
"$ref": "#/definitions/api.HTTPErrorResp"
}
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPErrorResp"
+ }
}
}
}
},
- "/api/admin/users/:action/:id": {
- "post": {
- "description": "Ban/unban a user based on his user id. This operation can only be done by admins",
- "consumes": [
+ "/api/admin/instances": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "produces": [
"application/json"
],
+ "tags": [
+ "admin"
+ ],
+ "summary": "List all active instances",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/api.AdminInstanceResponse"
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/admin/instances/challenge/{challenge_name}": {
+ "delete": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
"produces": [
"application/json"
],
"tags": [
"admin"
],
- "summary": "Ban/Unban a user based on his id and the action provided.",
+ "summary": "Delete all instances for a challenge",
"parameters": [
{
"type": "string",
- "description": "Action to perform ban/unban",
- "name": "action",
- "in": "query",
- "required": true
- },
- {
- "type": "string",
- "description": "Id of user",
- "name": "id",
- "in": "query",
+ "description": "Challenge name",
+ "name": "challenge_name",
+ "in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
- "schema": {
- "$ref": "#/definitions/api.ChallengeStatusResp"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/api.HTTPPlainResp"
- }
- },
- "500": {
- "description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/api.HTTPPlainResp"
}
@@ -121,66 +127,27 @@ var doc = `{
}
}
},
- "/api/config/competition-info": {
- "post": {
- "description": "Populates beast gobal config map by reparsing the config file $HOME/.beast/config.toml.",
- "consumes": [
- "application/json"
+ "/api/admin/instances/user/{user_id}": {
+ "delete": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
],
"produces": [
"application/json"
],
"tags": [
- "config"
+ "admin"
],
- "summary": "Updates competition info in the beast global configuration, located at ~/.beast/config.toml.",
+ "summary": "Delete all instances owned by a user",
"parameters": [
{
"type": "string",
- "description": "Competition Name",
- "name": "name",
- "in": "formData",
- "required": true
- },
- {
- "type": "string",
- "description": "Some information about competition",
- "name": "about",
- "in": "formData",
- "required": true
- },
- {
- "type": "string",
- "description": "Competitions Prizes for the winners",
- "name": "prizes",
- "in": "formData"
- },
- {
- "type": "string",
- "description": "Competition's starting time",
- "name": "starting_time",
- "in": "formData",
- "required": true
- },
- {
- "type": "string",
- "description": "Competition's ending time",
- "name": "ending_time",
- "in": "formData",
- "required": true
- },
- {
- "type": "string",
- "description": "Competition's timezone",
- "name": "timezone",
- "in": "formData",
+ "description": "User ID",
+ "name": "user_id",
+ "in": "path",
"required": true
- },
- {
- "type": "file",
- "description": "Competition's logo",
- "name": "logo",
- "in": "formData"
}
],
"responses": {
@@ -189,41 +156,30 @@ var doc = `{
"schema": {
"$ref": "#/definitions/api.HTTPPlainResp"
}
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/api.HTTPPlainResp"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/api.HTTPErrorResp"
- }
}
}
}
},
- "/api/config/reload/": {
- "patch": {
- "description": "Populates beast gobal config map by reparsing the config file $HOME/.beast/config.toml.",
- "consumes": [
- "application/json"
+ "/api/admin/instances/{instance_id}": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
],
"produces": [
"application/json"
],
"tags": [
- "config"
+ "admin"
],
- "summary": "Reloads any changes in beast global configuration, located at ~/.beast/config.toml.",
+ "summary": "Get an instance by ID",
"parameters": [
{
"type": "string",
- "description": "Bearer",
- "name": "Authorization",
- "in": "header",
+ "description": "Instance ID",
+ "name": "instance_id",
+ "in": "path",
"required": true
}
],
@@ -231,11 +187,36 @@ var doc = `{
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/api.HTTPPlainResp"
+ "$ref": "#/definitions/api.AdminInstanceResponse"
}
- },
- "400": {
- "description": "Bad Request",
+ }
+ }
+ },
+ "delete": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "admin"
+ ],
+ "summary": "Delete an instance by ID",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Instance ID",
+ "name": "instance_id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
"schema": {
"$ref": "#/definitions/api.HTTPPlainResp"
}
@@ -243,9 +224,9 @@ var doc = `{
}
}
},
- "/api/info/challenge/info": {
+ "/api/admin/leaderboard": {
"get": {
- "description": "Returns all information about the challenges by the challenge name.",
+ "description": "Returns admin leaderboard of all users",
"consumes": [
"application/json"
],
@@ -255,7 +236,7 @@ var doc = `{
"tags": [
"info"
],
- "summary": "Returns all information about the challenges.",
+ "summary": "Returns admin leaderboard",
"parameters": [
{
"type": "string",
@@ -266,17 +247,16 @@ var doc = `{
},
{
"type": "string",
- "description": "Name of challenge",
- "name": "name",
- "in": "query",
- "required": true
+ "description": "Page number",
+ "name": "page",
+ "in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/api.ChallengeInfoResp"
+ "$ref": "#/definitions/api.UserResp"
}
},
"400": {
@@ -285,12 +265,6 @@ var doc = `{
"$ref": "#/definitions/api.HTTPErrorResp"
}
},
- "404": {
- "description": "Not Found",
- "schema": {
- "$ref": "#/definitions/api.HTTPErrorResp"
- }
- },
"500": {
"description": "Internal Server Error",
"schema": {
@@ -300,9 +274,9 @@ var doc = `{
}
}
},
- "/api/info/challenges": {
+ "/api/admin/statistics": {
"get": {
- "description": "Returns information about all the challenges present in the database with and without filters.",
+ "description": "returns statistics of users in competition (currently limited to ban/unban status of users)",
"consumes": [
"application/json"
],
@@ -312,20 +286,8 @@ var doc = `{
"tags": [
"info"
],
- "summary": "Returns information about all challenges with and without filters.",
+ "summary": "statistics of users in competition",
"parameters": [
- {
- "type": "string",
- "description": "Filter parameter by which challenges are filtered",
- "name": "filter",
- "in": "query"
- },
- {
- "type": "string",
- "description": "Value of filtered parameter",
- "name": "value",
- "in": "query"
- },
{
"type": "string",
"description": "Bearer",
@@ -338,11 +300,11 @@ var doc = `{
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/api.ChallengeInfoResp"
+ "$ref": "#/definitions/api.UsersStatisticsResp"
}
},
- "400": {
- "description": "Bad Request",
+ "404": {
+ "description": "Not Found",
"schema": {
"$ref": "#/definitions/api.HTTPErrorResp"
}
@@ -356,9 +318,9 @@ var doc = `{
}
}
},
- "/api/info/images/available": {
+ "/api/admin/submissions": {
"get": {
- "description": "Returns all the available base images which can be used for challenge creation as the base OS for challenge.",
+ "description": "Handles submissions made by the user",
"consumes": [
"application/json"
],
@@ -368,7 +330,7 @@ var doc = `{
"tags": [
"info"
],
- "summary": "Gives all the base images that can be used while creating a beast challenge, this is a constant specified in beast global config",
+ "summary": "Handles submissions made by the user",
"parameters": [
{
"type": "string",
@@ -382,15 +344,21 @@ var doc = `{
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/api.AvailableImagesResp"
+ "$ref": "#/definitions/api.SubmissionResp"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPErrorResp"
}
}
}
}
},
- "/api/info/logs": {
- "get": {
- "description": "Gives container logs for a particular challenge, useful for debugging purposes.",
+ "/api/admin/unfreezeLeaderboard": {
+ "post": {
+ "description": "unfreezes the user leaderboard on demand.",
"consumes": [
"application/json"
],
@@ -400,7 +368,7 @@ var doc = `{
"tags": [
"info"
],
- "summary": "Handles route related to logs handling of container",
+ "summary": "Unfreeze user leaderboard",
"parameters": [
{
"type": "string",
@@ -408,39 +376,33 @@ var doc = `{
"name": "Authorization",
"in": "header",
"required": true
- },
- {
- "type": "string",
- "description": "The name of the challenge to get the logs for.",
- "name": "challenge",
- "in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/api.LogsInfoResp"
+ "$ref": "#/definitions/api.UserResp"
}
},
"400": {
"description": "Bad Request",
"schema": {
- "$ref": "#/definitions/api.HTTPPlainResp"
+ "$ref": "#/definitions/api.HTTPErrorResp"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
- "$ref": "#/definitions/api.HTTPPlainResp"
+ "$ref": "#/definitions/api.HTTPErrorResp"
}
}
}
}
},
- "/api/info/ports/used": {
- "get": {
- "description": "Returns the ports in use by beast, which cannot be used in creating a new challenge..",
+ "/api/admin/users/{action}/{id}": {
+ "post": {
+ "description": "Ban/Unban/Hide/Unhide a user based on his user id. This operation can only be done by admins",
"consumes": [
"application/json"
],
@@ -448,15 +410,22 @@ var doc = `{
"application/json"
],
"tags": [
- "info"
+ "admin"
],
- "summary": "Returns ports in use by beast by looking in the hack git repository, also returns min and max value of port allowed while specifying in beast challenge config.",
+ "summary": "Ban/Unban/Hide/Unhide a user based on his id and the action provided.",
"parameters": [
{
"type": "string",
- "description": "Bearer",
- "name": "Authorization",
- "in": "header",
+ "description": "Action to perform Ban/Unban/Hide/Unhide",
+ "name": "action",
+ "in": "query",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Id of user",
+ "name": "id",
+ "in": "query",
"required": true
}
],
@@ -464,15 +433,27 @@ var doc = `{
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/api.PortsInUseResp"
+ "$ref": "#/definitions/api.ChallengeStatusResp"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPPlainResp"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPPlainResp"
}
}
}
}
},
- "/api/info/submissions": {
- "get": {
- "description": "Handles submissions made by the user",
+ "/api/config/challenge-info": {
+ "post": {
+ "description": "Updates challenge info in the database, located at ~/.beast/beast.db.",
"consumes": [
"application/json"
],
@@ -480,93 +461,1052 @@ var doc = `{
"application/json"
],
"tags": [
- "info"
+ "config"
],
- "summary": "Handles submissions made by the user",
+ "summary": "Updates challenge info in the database, located at ~/.beast/beast.db.",
"parameters": [
{
"type": "string",
- "description": "Bearer",
- "name": "Authorization",
- "in": "header",
+ "description": "Challenge Name",
+ "name": "name",
+ "in": "formData",
"required": true
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/api.SubmissionResp"
- }
},
- "500": {
- "description": "Internal Server Error",
+ {
+ "type": "string",
+ "description": "Challenge's description",
+ "name": "desc",
+ "in": "formData"
+ },
+ {
+ "type": "string",
+ "description": "Challenge's points",
+ "name": "points",
+ "in": "formData"
+ },
+ {
+ "type": "string",
+ "description": "Challenge's flag",
+ "name": "flag",
+ "in": "formData"
+ },
+ {
+ "type": "string",
+ "description": "Challenge's tags",
+ "name": "tags",
+ "in": "formData"
+ },
+ {
+ "type": "string",
+ "description": "Challenge's ports",
+ "name": "ports",
+ "in": "formData"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPPlainResp"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPPlainResp"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPErrorResp"
+ }
+ }
+ }
+ }
+ },
+ "/api/info/challenge/{name}": {
+ "get": {
+ "description": "Returns all information about the challenges by the challenge name.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "info"
+ ],
+ "summary": "Returns all information about the challenges.",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Bearer",
+ "name": "Authorization",
+ "in": "header",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Name of challenge",
+ "name": "name",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/api.Challenge"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPErrorResp"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPErrorResp"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPErrorResp"
+ }
+ }
+ }
+ }
+ },
+ "/api/info/challenges": {
+ "get": {
+ "description": "Returns information about all the challenges present in the database with and without filters.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "info"
+ ],
+ "summary": "Returns metadata about all challenges with and without filters.",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Filter parameter by which challenges are filtered",
+ "name": "filter",
+ "in": "query"
+ },
+ {
+ "type": "string",
+ "description": "Value of filtered parameter",
+ "name": "value",
+ "in": "query"
+ },
+ {
+ "type": "string",
+ "description": "Bearer",
+ "name": "Authorization",
+ "in": "header",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/api.ChallengeMetadata"
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPErrorResp"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPErrorResp"
+ }
+ }
+ }
+ }
+ },
+ "/api/info/competition-info": {
+ "get": {
+ "description": "returns various information about the competition which are used to control competition",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "info"
+ ],
+ "summary": "returns competition info",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Bearer",
+ "name": "Authorization",
+ "in": "header",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/api.CompetitionInfoResp"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPErrorResp"
+ }
+ }
+ }
+ }
+ },
+ "/api/info/download": {
+ "get": {
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "info"
+ ],
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Bearer",
+ "name": "Authorization",
+ "in": "header",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "The name of the challenge to get the logs for.",
+ "name": "challenge",
+ "in": "query"
+ },
+ {
+ "type": "string",
+ "description": "The name of the static asset requested.",
+ "name": "asset",
+ "in": "query"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/api.LogsInfoResp"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPPlainResp"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPPlainResp"
+ }
+ }
+ }
+ }
+ },
+ "/api/info/hint/{hintID}": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "info"
+ ],
+ "summary": "Read or purchase a challenge hint",
+ "parameters": [
+ {
+ "type": "integer",
+ "description": "Hint ID",
+ "name": "hintID",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/api.HintResponse"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPErrorResp"
+ }
+ }
+ }
+ },
+ "post": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "info"
+ ],
+ "summary": "Read or purchase a challenge hint",
+ "parameters": [
+ {
+ "type": "integer",
+ "description": "Hint ID",
+ "name": "hintID",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/api.HintResponse"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPErrorResp"
+ }
+ }
+ }
+ }
+ },
+ "/api/info/images/available": {
+ "get": {
+ "description": "Returns all the available base images which can be used for challenge creation as the base OS for challenge.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "info"
+ ],
+ "summary": "Gives all the base images that can be used while creating a beast challenge, this is a constant specified in beast global config",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Bearer",
+ "name": "Authorization",
+ "in": "header",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/api.AvailableImagesResp"
+ }
+ }
+ }
+ }
+ },
+ "/api/info/leaderboard": {
+ "get": {
+ "description": "Returns leaderboard of all users",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "info"
+ ],
+ "summary": "Returns leaderboard",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Bearer",
+ "name": "Authorization",
+ "in": "header",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Page number",
+ "name": "page",
+ "in": "query"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/api.UserResp"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPErrorResp"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPErrorResp"
+ }
+ }
+ }
+ }
+ },
+ "/api/info/leaderboard-graph": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "info"
+ ],
+ "summary": "Return leaderboard score history",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/database.UserLeaderboardResp"
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/info/submissions/challenge/{challenge_id}": {
+ "get": {
+ "description": "Returns all user attempts for a given challenge.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "info"
+ ],
+ "summary": "Get challenge attempts",
+ "parameters": [
+ {
+ "type": "integer",
+ "description": "Challenge ID",
+ "name": "challenge_id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Bearer",
+ "name": "Authorization",
+ "in": "header",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/api.UserSolveResp"
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPErrorResp"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPErrorResp"
+ }
+ }
+ }
+ }
+ },
+ "/api/info/submissions/user/{user_id}": {
+ "get": {
+ "description": "Returns all submissions for a specific user",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "info"
+ ],
+ "summary": "Get submissions by user",
+ "parameters": [
+ {
+ "type": "integer",
+ "description": "User ID",
+ "name": "user_id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Bearer",
+ "name": "Authorization",
+ "in": "header",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/api.SubmissionResp"
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPErrorResp"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPErrorResp"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPErrorResp"
+ }
+ }
+ }
+ }
+ },
+ "/api/info/tags": {
+ "get": {
+ "description": "returns all unique tags",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "info"
+ ],
+ "summary": "returns all tags",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Bearer",
+ "name": "Authorization",
+ "in": "header",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/api.TagInfoResp"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPErrorResp"
+ }
+ }
+ }
+ }
+ },
+ "/api/info/user/{username}": {
+ "get": {
+ "description": "Returns user info based on userId",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "info"
+ ],
+ "summary": "Returns user info",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Bearer",
+ "name": "Authorization",
+ "in": "header",
+ "required": true
+ },
+ {
+ "type": "integer",
+ "description": "User ID",
+ "name": "user_id",
+ "in": "query"
+ },
+ {
+ "type": "string",
+ "description": "Username",
+ "name": "username",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/api.UserResp"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPErrorResp"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPErrorResp"
+ }
+ }
+ }
+ }
+ },
+ "/api/info/usercount": {
+ "get": {
+ "description": "Returns the number of users in the database with role=contestant",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "info"
+ ],
+ "summary": "Returns the number of users in the database with role=contestant",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Bearer",
+ "name": "Authorization",
+ "in": "header",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/api.UserCountResp"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPErrorResp"
+ }
+ }
+ }
+ }
+ },
+ "/api/info/users": {
+ "get": {
+ "description": "Returns all available user's info",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "info"
+ ],
+ "summary": "Returns all user's info",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Bearer",
+ "name": "Authorization",
+ "in": "header",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Sort by username or score",
+ "name": "sort",
+ "in": "query"
+ },
+ {
+ "type": "string",
+ "description": "Score order: asc or desc",
+ "name": "order",
+ "in": "query"
+ },
+ {
+ "type": "string",
+ "description": "Filter by banned, active, or hidden",
+ "name": "filter",
+ "in": "query"
+ },
+ {
+ "type": "string",
+ "description": "Response format: json or csv",
+ "name": "format",
+ "in": "query"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/api.UsersResp"
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPErrorResp"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPErrorResp"
+ }
+ }
+ }
+ }
+ },
+ "/api/instances": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "instances"
+ ],
+ "summary": "List the current user's instances",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/api.InstanceResponse"
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/instances/{challenge_name}": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "instances"
+ ],
+ "summary": "Get the current user's challenge instance",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Challenge name",
+ "name": "challenge_name",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/api.InstanceResponse"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPErrorResp"
+ }
+ }
+ }
+ },
+ "delete": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "instances"
+ ],
+ "summary": "Delete the current user's challenge instance",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Challenge name",
+ "name": "challenge_name",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPPlainResp"
+ }
+ }
+ }
+ }
+ },
+ "/api/instances/{challenge_name}/extend": {
+ "post": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "instances"
+ ],
+ "summary": "Extend the current user's challenge instance",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Challenge name",
+ "name": "challenge_name",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "integer",
+ "description": "Requested extension in seconds",
+ "name": "seconds",
+ "in": "formData"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/api.InstanceResponse"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPErrorResp"
+ }
+ }
+ }
+ }
+ },
+ "/api/instances/{challenge_name}/spawn": {
+ "post": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "instances"
+ ],
+ "summary": "Spawn a per-user challenge instance",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Challenge name",
+ "name": "challenge_name",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/api.InstanceResponse"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPErrorResp"
+ }
+ }
+ }
+ }
+ },
+ "/api/manage/challenge/": {
+ "post": {
+ "description": "Handles challenge management routes with actions which includes - DEPLOY, UNDEPLOY, PURGE.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "manage"
+ ],
+ "summary": "Handles challenge management actions.",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Name of the challenge to be managed, here name is the unique identifier for challenge",
+ "name": "name",
+ "in": "query",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Action for the challenge",
+ "name": "action",
+ "in": "query",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
"schema": {
- "$ref": "#/definitions/api.HTTPErrorResp"
+ "$ref": "#/definitions/api.HTTPPlainResp"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPPlainResp"
}
}
}
}
},
- "/api/info/user": {
- "get": {
- "description": "Returns user info based on userId",
+ "/api/manage/challenge/multiple/": {
+ "post": {
+ "description": "Handles challenge management routes with actions which includes - DEPLOY, UNDEPLOY, PURGE of multiple challenges.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
- "tags": [
- "info"
- ],
- "summary": "Returns user info",
+ "summary": "Handles multiple challenge management actions.",
"parameters": [
{
"type": "string",
- "description": "Bearer",
- "name": "Authorization",
- "in": "header",
+ "description": "Name of the challenge to be managed, here name is the unique identifier for challenges seperated by a comma",
+ "name": "name",
+ "in": "query",
"required": true
},
{
"type": "string",
- "description": "User's id",
- "name": "value",
- "in": "formData"
- },
- {
- "type": "string",
- "description": "username",
- "name": "value",
- "in": "query"
+ "description": "Action for the challenge",
+ "name": "action",
+ "in": "query",
+ "required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/api.UserResp"
+ "$ref": "#/definitions/api.HTTPPlainResp"
}
},
"400": {
"description": "Bad Request",
"schema": {
- "$ref": "#/definitions/api.HTTPErrorResp"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/api.HTTPErrorResp"
+ "$ref": "#/definitions/api.HTTPPlainResp"
}
}
}
}
},
- "/api/info/user/available": {
- "get": {
- "description": "Returns all available user's info",
+ "/api/manage/challenge/upload": {
+ "post": {
+ "description": "Handles the challenge management from a challenge in zip file. Currently prepare the zip file",
"consumes": [
"application/json"
],
@@ -574,15 +1514,15 @@ var doc = `{
"application/json"
],
"tags": [
- "info"
+ "manage"
],
- "summary": "Returns all user's info",
+ "summary": "Unzip and fetch info from beast.toml file in challenge",
"parameters": [
{
- "type": "string",
- "description": "Bearer",
- "name": "Authorization",
- "in": "header",
+ "type": "file",
+ "description": ".zip file to be uploaded to fetch challenge info",
+ "name": "file",
+ "in": "formData",
"required": true
}
],
@@ -590,11 +1530,11 @@ var doc = `{
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/api.UserResp"
+ "$ref": "#/definitions/api.ChallengePreviewResp"
}
},
- "404": {
- "description": "Not Found",
+ "400": {
+ "description": "Bad Request",
"schema": {
"$ref": "#/definitions/api.HTTPErrorResp"
}
@@ -608,11 +1548,12 @@ var doc = `{
}
}
},
- "/api/manage/challenge/": {
+ "/api/manage/challenge/validateflag": {
"post": {
- "description": "Handles challenge management routes with actions which includes - DEPLOY, UNDEPLOY, PURGE.",
- "consumes": [
- "application/json"
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
],
"produces": [
"application/json"
@@ -620,19 +1561,57 @@ var doc = `{
"tags": [
"manage"
],
- "summary": "Handles challenge management actions.",
+ "summary": "Validate a configured challenge flag as its manager",
"parameters": [
{
"type": "string",
- "description": "Name of the challenge to be managed, here name is the unique identifier for challenge",
- "name": "name",
- "in": "query",
+ "description": "Challenge name",
+ "name": "challenge_name",
+ "in": "formData",
"required": true
},
{
"type": "string",
- "description": "Action for the challenge",
- "name": "action",
+ "description": "Flag to validate",
+ "name": "flag",
+ "in": "formData",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPPlainResp"
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPErrorResp"
+ }
+ }
+ }
+ }
+ },
+ "/api/manage/challenge/verify": {
+ "post": {
+ "description": "Commits the challenge container for later use",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "manage"
+ ],
+ "summary": "Commits the challenge container so that later the challenge image can be used deployment",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Name of the challenge to commit",
+ "name": "challenge",
"in": "query",
"required": true
}
@@ -644,8 +1623,8 @@ var doc = `{
"$ref": "#/definitions/api.HTTPPlainResp"
}
},
- "400": {
- "description": "Bad Request",
+ "500": {
+ "description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/api.HTTPPlainResp"
}
@@ -653,9 +1632,13 @@ var doc = `{
}
}
},
- "/api/manage/challenge/upload": {
+ "/api/manage/challenge/{name}/exec": {
"post": {
- "description": "Handles the challenge management from a challenge in tar file. Currently prepare the tar file",
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
"consumes": [
"application/json"
],
@@ -665,21 +1648,30 @@ var doc = `{
"tags": [
"manage"
],
- "summary": "Untar and fetch info from beast.toml file in challenge",
+ "summary": "Execute an argument-vector command in an owned challenge container",
"parameters": [
{
- "type": "file",
- "description": ".tar file to be uploaded to fetch challenge info",
- "name": "file",
- "in": "formData",
+ "type": "string",
+ "description": "Challenge name",
+ "name": "name",
+ "in": "path",
"required": true
+ },
+ {
+ "description": "Bounded exec request",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/api.ExecChallengeRequest"
+ }
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/api.ChallengePreviewResp"
+ "$ref": "#/definitions/api.ExecChallengeResponse"
}
},
"400": {
@@ -688,8 +1680,8 @@ var doc = `{
"$ref": "#/definitions/api.HTTPErrorResp"
}
},
- "500": {
- "description": "Internal Server Error",
+ "403": {
+ "description": "Forbidden",
"schema": {
"$ref": "#/definitions/api.HTTPErrorResp"
}
@@ -729,7 +1721,7 @@ var doc = `{
}
}
},
- "/api/manage/deploy/local": {
+ "/api/manage/deploy/local/": {
"post": {
"description": "Handles deployment of a challenge using the absolute directory path",
"consumes": [
@@ -773,7 +1765,62 @@ var doc = `{
}
}
},
- "/api/manage/multiple/:action": {
+ "/api/manage/logs": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Gives container logs for a particular challenge, useful for debugging purposes.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "info"
+ ],
+ "summary": "Handles route related to logs handling of container",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Bearer",
+ "name": "Authorization",
+ "in": "header",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "The name of the challenge to get the logs for.",
+ "name": "challenge",
+ "in": "query"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/api.LogsInfoResp"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPPlainResp"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPPlainResp"
+ }
+ }
+ }
+ }
+ },
+ "/api/manage/multiple/{action}": {
"post": {
"description": "Handles challenge management routes for multiple the challenges with actions which includes - DEPLOY, UNDEPLOY.",
"consumes": [
@@ -817,7 +1864,7 @@ var doc = `{
}
}
},
- "/api/manage/schedule/:action": {
+ "/api/manage/schedule/{action}": {
"post": {
"description": "Handles scheduleing of challenge action to executed at some later point of time",
"consumes": [
@@ -886,7 +1933,7 @@ var doc = `{
}
}
},
- "/api/manage/static/:action": {
+ "/api/manage/static/{action}": {
"post": {
"description": "Handles beast static content serving container routes.",
"consumes": [
@@ -976,7 +2023,7 @@ var doc = `{
}
},
"/api/notification/available": {
- "post": {
+ "get": {
"description": "Fetch all the notifications from database",
"consumes": [
"application/json"
@@ -1005,7 +2052,7 @@ var doc = `{
}
},
"/api/notification/delete": {
- "post": {
+ "delete": {
"description": "Removes notifications",
"consumes": [
"application/json"
@@ -1048,8 +2095,32 @@ var doc = `{
}
}
},
+ "/api/notification/stream": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "produces": [
+ "text/event-stream"
+ ],
+ "tags": [
+ "notification"
+ ],
+ "summary": "Stream server-sent notifications",
+ "responses": {
+ "200": {
+ "description": "SSE stream",
+ "schema": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
"/api/notification/update": {
- "post": {
+ "put": {
"description": "Updates any changes in the notifications",
"consumes": [
"application/json"
@@ -1106,7 +2177,7 @@ var doc = `{
}
}
},
- "/api/remote/reset/": {
+ "/api/remote/reset": {
"post": {
"description": "Resets local copy of remote git directory, it first deletes the existing directory and then clone from the remote again.",
"consumes": [
@@ -1144,7 +2215,7 @@ var doc = `{
}
}
},
- "/api/remote/sync/": {
+ "/api/remote/sync": {
"post": {
"description": "Syncs beasts local challenges database with the remote git repository(hack) the local copy of the challenge database is located at $HOME/.beast/remote/$REMOTE_NAME.",
"consumes": [
@@ -1182,7 +2253,61 @@ var doc = `{
}
}
},
- "/api/status/all/:filter": {
+ "/api/status/all": {
+ "get": {
+ "description": "This returns the challenges in the status provided, along with their name and last updated time.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "status"
+ ],
+ "summary": "Returns challenge deployment status from the beast database for the challenges which matches the stauts according to filter.",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Status type to filter with, if none specified then all",
+ "name": "filter",
+ "in": "query",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Bearer",
+ "name": "Authorization",
+ "in": "header",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/api.ChallengeStatusResp"
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPPlainResp"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPPlainResp"
+ }
+ }
+ }
+ }
+ },
+ "/api/status/all/{filter}": {
"get": {
"description": "This returns the challenges in the status provided, along with their name and last updated time.",
"consumes": [
@@ -1236,7 +2361,7 @@ var doc = `{
}
}
},
- "/api/status/challenge/:name": {
+ "/api/status/challenge/{name}": {
"get": {
"description": "Returns challenge deployment status from the beast database, for those challenges which are not present a status value NA is returned.",
"consumes": [
@@ -1438,12 +2563,6 @@ var doc = `{
"name": "email",
"in": "formData",
"required": true
- },
- {
- "type": "string",
- "description": "User's ssh-key",
- "name": "ssh-key",
- "in": "formData"
}
],
"responses": {
@@ -1508,9 +2627,209 @@ var doc = `{
}
}
}
+ },
+ "/auth/send-otp": {
+ "post": {
+ "consumes": [
+ "multipart/form-data"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "auth"
+ ],
+ "summary": "Send an email verification OTP",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Email address",
+ "name": "email",
+ "in": "formData",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPPlainResp"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPErrorResp"
+ }
+ }
+ }
+ }
+ },
+ "/auth/send-otp-forget": {
+ "post": {
+ "consumes": [
+ "multipart/form-data"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "auth"
+ ],
+ "summary": "Send a password-reset OTP",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Registered email address",
+ "name": "email",
+ "in": "formData",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPPlainResp"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPErrorResp"
+ }
+ }
+ }
+ }
+ },
+ "/auth/verify-otp": {
+ "post": {
+ "consumes": [
+ "multipart/form-data"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "auth"
+ ],
+ "summary": "Verify an email OTP",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Email address",
+ "name": "email",
+ "in": "formData",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "One-time code",
+ "name": "otp",
+ "in": "formData",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPPlainResp"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPErrorResp"
+ }
+ }
+ }
+ }
+ },
+ "/auth/verify-otp-forget": {
+ "post": {
+ "consumes": [
+ "multipart/form-data"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "auth"
+ ],
+ "summary": "Verify a password-reset OTP",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Registered email address",
+ "name": "email",
+ "in": "formData",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "One-time code",
+ "name": "otp",
+ "in": "formData",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPAuthorizeResp"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPErrorResp"
+ }
+ }
+ }
+ }
}
},
"definitions": {
+ "api.AdminInstanceResponse": {
+ "type": "object",
+ "properties": {
+ "challenge_name": {
+ "type": "string"
+ },
+ "container_id": {
+ "type": "string"
+ },
+ "created_at": {
+ "type": "string"
+ },
+ "deployment_type": {
+ "type": "string"
+ },
+ "expires_at": {
+ "type": "string"
+ },
+ "hosted_address": {
+ "type": "string"
+ },
+ "instance_id": {
+ "type": "string"
+ },
+ "port": {
+ "type": "integer"
+ },
+ "ttl_seconds": {
+ "type": "integer"
+ },
+ "user_id": {
+ "type": "string"
+ },
+ "username": {
+ "type": "string"
+ }
+ }
+ },
"api.AvailableImagesResp": {
"type": "object",
"properties": {
@@ -1520,8 +2839,8 @@ var doc = `{
"type": "string"
},
"example": [
- "['ubuntu16.04'",
- " 'ubuntu18.04']"
+ "ubuntu:24.04",
+ "debian:bookworm"
]
},
"message": {
@@ -1530,9 +2849,29 @@ var doc = `{
}
}
},
- "api.ChallengeInfoResp": {
+ "api.Challenge": {
"type": "object",
"properties": {
+ "additionalLinks": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "example": [
+ "https://link1.example",
+ "https://link2.example"
+ ]
+ },
+ "assets": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "example": [
+ "image1.png",
+ "zippy.zip"
+ ]
+ },
"category": {
"type": "string",
"example": "web"
@@ -1540,14 +2879,111 @@ var doc = `{
"createdAt": {
"type": "string"
},
+ "deployedLink": {
+ "type": "string",
+ "example": "beast.sdslabs.co or ip:port"
+ },
+ "deployedStatus": {
+ "type": "string",
+ "example": "deployed"
+ },
"description": {
- "type": "string"
+ "type": "string",
+ "example": "A simple web challenge"
+ },
+ "difficulty": {
+ "type": "string",
+ "example": "easy"
},
"hints": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/api.HintInfo"
+ }
+ },
+ "id": {
+ "type": "integer",
+ "example": 0
+ },
+ "instanceExpiration": {
+ "type": "integer",
+ "example": 300
+ },
+ "instanced": {
+ "type": "boolean",
+ "example": false
+ },
+ "maxAttemptLimit": {
+ "type": "integer",
+ "example": 5
+ },
+ "name": {
+ "type": "string",
+ "example": "Web Challenge"
+ },
+ "points": {
+ "type": "integer",
+ "example": 50
+ },
+ "preRequisite": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "example": [
+ "chall1",
+ "chall2"
+ ]
+ },
+ "previousTries": {
+ "type": "integer",
+ "example": 3
+ },
+ "solveStatus": {
+ "type": "boolean",
+ "example": true
+ },
+ "solvesNumber": {
+ "type": "integer",
+ "example": 100
+ },
+ "tags": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "example": [
+ "pwn",
+ "misc"
+ ]
+ }
+ }
+ },
+ "api.ChallengeMetadata": {
+ "type": "object",
+ "properties": {
+ "createdAt": {
"type": "string"
},
+ "deployedStatus": {
+ "type": "string",
+ "example": "deployed"
+ },
+ "difficulty": {
+ "type": "string",
+ "example": "easy"
+ },
"id": {
- "type": "integer"
+ "type": "integer",
+ "example": 0
+ },
+ "instanceExpiration": {
+ "type": "integer",
+ "example": 300
+ },
+ "instanced": {
+ "type": "boolean",
+ "example": false
},
"name": {
"type": "string",
@@ -1557,43 +2993,74 @@ var doc = `{
"type": "integer",
"example": 50
},
- "ports": {
+ "preRequisite": {
"type": "array",
"items": {
- "type": "integer"
- }
+ "type": "string"
+ },
+ "example": [
+ "chall1",
+ "chall2"
+ ]
},
- "solves": {
- "type": "array",
- "items": {
- "$ref": "#/definitions/api.UserSolveResp"
- }
+ "solveStatus": {
+ "type": "boolean",
+ "example": true
},
"solvesNumber": {
"type": "integer",
"example": 100
},
- "status": {
- "type": "string",
- "example": "deployed"
+ "tags": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "example": [
+ "pwn",
+ "misc"
+ ]
}
}
},
"api.ChallengePreviewResp": {
"type": "object",
"properties": {
+ "additionalLinks": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "example": [
+ "https://link1.example",
+ "https://link2.example"
+ ]
+ },
+ "assets": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "example": [
+ "image1.png",
+ "zippy.zip"
+ ]
+ },
"category": {
"type": "string",
"example": "web"
},
+ "deployedLink": {
+ "type": "string",
+ "example": "beast.sdslabs.co"
+ },
"description": {
- "type": "string"
+ "type": "string",
+ "example": "A simple web challenge"
},
- "hints": {
- "type": "array",
- "items": {
- "type": "string"
- }
+ "maxAttemptLimit": {
+ "type": "integer",
+ "example": 5
},
"name": {
"type": "string",
@@ -1607,7 +3074,31 @@ var doc = `{
"type": "array",
"items": {
"type": "integer"
- }
+ },
+ "example": [
+ 3001,
+ 3002
+ ]
+ },
+ "preRequisite": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "example": [
+ "web-php",
+ "simple"
+ ]
+ },
+ "tags": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "example": [
+ "pwn",
+ "misc"
+ ]
}
}
},
@@ -1616,7 +3107,7 @@ var doc = `{
"properties": {
"category": {
"type": "string",
- "example": "web"
+ "example": "bare"
},
"id": {
"type": "integer",
@@ -1632,6 +3123,16 @@ var doc = `{
},
"solvedAt": {
"type": "string"
+ },
+ "tags": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "example": [
+ "pwn",
+ "misc"
+ ]
}
}
},
@@ -1677,7 +3178,42 @@ var doc = `{
"type": "string"
},
"timezone": {
+ "type": "string",
+ "example": "Asia/Calcutta: UTC +05:30"
+ }
+ }
+ },
+ "api.ExecChallengeRequest": {
+ "type": "object",
+ "properties": {
+ "command": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "instance_id": {
+ "type": "string"
+ },
+ "timeout_seconds": {
+ "type": "integer"
+ }
+ }
+ },
+ "api.ExecChallengeResponse": {
+ "type": "object",
+ "properties": {
+ "exit_code": {
+ "type": "integer"
+ },
+ "stderr": {
+ "type": "string"
+ },
+ "stdout": {
"type": "string"
+ },
+ "truncated": {
+ "type": "boolean"
}
}
},
@@ -1716,60 +3252,95 @@ var doc = `{
}
}
},
- "api.LogsInfoResp": {
+ "api.HintInfo": {
"type": "object",
"properties": {
- "stderr": {
- "type": "string",
- "example": "[ERROR] Challenge deployment failed."
+ "id": {
+ "type": "integer"
},
- "stdout": {
- "type": "string",
- "example": "[INFO] Challenge is starting to deploy"
+ "points": {
+ "type": "integer"
}
}
},
- "api.PortsInUseResp": {
+ "api.HintResponse": {
"type": "object",
"properties": {
- "port_max_value": {
- "type": "integer",
- "example": 20000
+ "description": {
+ "type": "string",
+ "example": "This is a hint"
},
- "port_min_value": {
+ "points": {
"type": "integer",
- "example": 10000
+ "example": 10
+ }
+ }
+ },
+ "api.InstanceResponse": {
+ "type": "object",
+ "properties": {
+ "challenge_name": {
+ "type": "string"
},
- "ports_in_use": {
- "type": "array",
- "items": {
- "type": "integer"
- }
+ "created_at": {
+ "type": "string"
+ },
+ "expires_at": {
+ "type": "string"
+ },
+ "hosted_address": {
+ "type": "string"
+ },
+ "instance_id": {
+ "type": "string"
+ },
+ "port": {
+ "type": "integer"
+ },
+ "ttl_seconds": {
+ "type": "integer"
}
}
},
- "api.SubmissionResp": {
+ "api.LogsInfoResp": {
"type": "object",
"properties": {
- "category": {
+ "stderr": {
"type": "string",
- "example": "web"
+ "example": "[ERROR] Challenge deployment failed."
},
+ "stdout": {
+ "type": "string",
+ "example": "[INFO] Challenge is starting to deploy"
+ }
+ }
+ },
+ "api.SubmissionResp": {
+ "type": "object",
+ "properties": {
"chall_id": {
"type": "integer",
"example": 3
},
+ "cheating": {
+ "type": "boolean",
+ "example": false
+ },
+ "flag": {
+ "type": "string",
+ "example": "flag{@#$}"
+ },
"name": {
"type": "string",
"example": "Web Challenge"
},
- "points": {
- "type": "integer",
- "example": 50
- },
"solvedAt": {
"type": "string"
},
+ "success": {
+ "type": "boolean",
+ "example": true
+ },
"user_id": {
"type": "integer",
"example": 3
@@ -1780,6 +3351,25 @@ var doc = `{
}
}
},
+ "api.TagInfoResp": {
+ "type": "object",
+ "properties": {
+ "tags": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "api.UserCountResp": {
+ "type": "object",
+ "properties": {
+ "user_count": {
+ "type": "integer"
+ }
+ }
+ },
"api.UserResp": {
"type": "object",
"properties": {
@@ -1822,6 +3412,14 @@ var doc = `{
"api.UserSolveResp": {
"type": "object",
"properties": {
+ "correct": {
+ "type": "boolean",
+ "example": true
+ },
+ "flag": {
+ "type": "string",
+ "example": "flag{example_flag}"
+ },
"id": {
"type": "integer",
"example": 5
@@ -1835,18 +3433,93 @@ var doc = `{
}
}
},
+ "api.UsersResp": {
+ "type": "object",
+ "properties": {
+ "email": {
+ "type": "string",
+ "example": "fristonio@gmail.com"
+ },
+ "id": {
+ "type": "integer",
+ "example": 5
+ },
+ "rank": {
+ "type": "integer",
+ "example": 15
+ },
+ "role": {
+ "type": "string",
+ "example": "author"
+ },
+ "score": {
+ "type": "integer",
+ "example": 750
+ },
+ "status": {
+ "type": "integer",
+ "example": 0
+ },
+ "username": {
+ "type": "string",
+ "example": "CTF is live now!"
+ }
+ }
+ },
"api.UsersStatisticsResp": {
"type": "object",
"properties": {
"banned_users": {
- "type": "integer"
+ "type": "integer",
+ "example": 60
},
"total_registered_users": {
"type": "integer",
"example": 120
},
"unbanned_users": {
- "type": "integer"
+ "type": "integer",
+ "example": 60
+ }
+ }
+ },
+ "database.TimeSeries": {
+ "type": "object",
+ "properties": {
+ "score": {
+ "type": "integer",
+ "example": 750
+ },
+ "timestamp": {
+ "type": "string",
+ "example": "2018-12-31T22:20:08"
+ }
+ }
+ },
+ "database.UserLeaderboardResp": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "integer",
+ "example": 5
+ },
+ "rank": {
+ "type": "integer",
+ "example": 15
+ },
+ "score": {
+ "type": "integer",
+ "example": 750
+ },
+ "timeSeriesData": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/database.TimeSeries"
+ }
+ },
+ "username": {
+ "type": "string",
+ "example": "ABCD"
}
}
}
@@ -1860,49 +3533,20 @@ var doc = `{
}
}`
-type swaggerInfo struct {
- Version string
- Host string
- BasePath string
- Schemes []string
- Title string
- Description string
-}
-
// SwaggerInfo holds exported Swagger Info so clients can modify it
-var SwaggerInfo = swaggerInfo{
- Version: "1.0",
- Host: "beast.sdslabs.co",
- BasePath: "/",
- Schemes: []string{},
- Title: "Beast API",
- Description: "Beast the automatic deployment tool for backdoor",
-}
-
-type s struct{}
-
-func (s *s) ReadDoc() string {
- sInfo := SwaggerInfo
- sInfo.Description = strings.Replace(sInfo.Description, "\n", "\\n", -1)
-
- t, err := template.New("swagger_info").Funcs(template.FuncMap{
- "marshal": func(v interface{}) string {
- a, _ := json.Marshal(v)
- return string(a)
- },
- }).Parse(doc)
- if err != nil {
- return doc
- }
-
- var tpl bytes.Buffer
- if err := t.Execute(&tpl, sInfo); err != nil {
- return doc
- }
-
- return tpl.String()
+var SwaggerInfo = &swag.Spec{
+ Version: "0.2",
+ Host: "",
+ BasePath: "/",
+ Schemes: []string{"https"},
+ Title: "Beast API",
+ Description: "Authenticated API for Beast CTF challenge deployment and competition services.",
+ InfoInstanceName: "swagger",
+ SwaggerTemplate: docTemplate,
+ LeftDelim: "{{",
+ RightDelim: "}}",
}
func init() {
- swag.Register(swag.Name, &s{})
+ swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo)
}
diff --git a/api/docs/swagger.json b/api/docs/swagger.json
index 2294d64b..bcd196f1 100644
--- a/api/docs/swagger.json
+++ b/api/docs/swagger.json
@@ -1,25 +1,27 @@
{
+ "schemes": [
+ "https"
+ ],
"swagger": "2.0",
"info": {
- "description": "Beast the automatic deployment tool for backdoor",
+ "description": "Authenticated API for Beast CTF challenge deployment and competition services.",
"title": "Beast API",
"contact": {
"name": "SDSLabs",
"url": "https://chat.sdslabs.co",
- "email": "contact.sdslabs.co.in"
+ "email": "contact@sdslabs.co.in"
},
"license": {
"name": "Apache 2.0",
- "url": "http://www.apache.org/licenses/LICENSE-2.0.html"
+ "url": "https://www.apache.org/licenses/LICENSE-2.0.html"
},
- "version": "1.0"
+ "version": "0.2"
},
- "host": "beast.sdslabs.co",
"basePath": "/",
"paths": {
- "/api/admin/statistics": {
- "get": {
- "description": "returns various information about the competition which are used to control competition",
+ "/api/admin/freezeLeaderboard": {
+ "post": {
+ "description": "freezes the user leaderboard on demand.",
"consumes": [
"application/json"
],
@@ -29,7 +31,7 @@
"tags": [
"info"
],
- "summary": "returns competition info",
+ "summary": "Freeze user leaderboard",
"parameters": [
{
"type": "string",
@@ -43,7 +45,7 @@
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/api.CompetitionInfoResp"
+ "$ref": "#/definitions/api.UserResp"
}
},
"400": {
@@ -51,164 +53,63 @@
"schema": {
"$ref": "#/definitions/api.HTTPErrorResp"
}
- }
- }
- }
- },
- "/api/admin/users/:action/:id": {
- "post": {
- "description": "Ban/unban a user based on his user id. This operation can only be done by admins",
- "consumes": [
- "application/json"
- ],
- "produces": [
- "application/json"
- ],
- "tags": [
- "admin"
- ],
- "summary": "Ban/Unban a user based on his id and the action provided.",
- "parameters": [
- {
- "type": "string",
- "description": "Action to perform ban/unban",
- "name": "action",
- "in": "query",
- "required": true
- },
- {
- "type": "string",
- "description": "Id of user",
- "name": "id",
- "in": "query",
- "required": true
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/api.ChallengeStatusResp"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/api.HTTPPlainResp"
- }
},
"500": {
"description": "Internal Server Error",
"schema": {
- "$ref": "#/definitions/api.HTTPPlainResp"
+ "$ref": "#/definitions/api.HTTPErrorResp"
}
}
}
}
},
- "/api/config/competition-info": {
- "post": {
- "description": "Populates beast gobal config map by reparsing the config file $HOME/.beast/config.toml.",
- "consumes": [
- "application/json"
+ "/api/admin/instances": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
],
"produces": [
"application/json"
],
"tags": [
- "config"
- ],
- "summary": "Updates competition info in the beast global configuration, located at ~/.beast/config.toml.",
- "parameters": [
- {
- "type": "string",
- "description": "Competition Name",
- "name": "name",
- "in": "formData",
- "required": true
- },
- {
- "type": "string",
- "description": "Some information about competition",
- "name": "about",
- "in": "formData",
- "required": true
- },
- {
- "type": "string",
- "description": "Competitions Prizes for the winners",
- "name": "prizes",
- "in": "formData"
- },
- {
- "type": "string",
- "description": "Competition's starting time",
- "name": "starting_time",
- "in": "formData",
- "required": true
- },
- {
- "type": "string",
- "description": "Competition's ending time",
- "name": "ending_time",
- "in": "formData",
- "required": true
- },
- {
- "type": "string",
- "description": "Competition's timezone",
- "name": "timezone",
- "in": "formData",
- "required": true
- },
- {
- "type": "file",
- "description": "Competition's logo",
- "name": "logo",
- "in": "formData"
- }
+ "admin"
],
+ "summary": "List all active instances",
"responses": {
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/api.HTTPPlainResp"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/api.HTTPPlainResp"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/api.HTTPErrorResp"
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/api.AdminInstanceResponse"
+ }
}
}
}
}
},
- "/api/config/reload/": {
- "patch": {
- "description": "Populates beast gobal config map by reparsing the config file $HOME/.beast/config.toml.",
- "consumes": [
- "application/json"
+ "/api/admin/instances/challenge/{challenge_name}": {
+ "delete": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
],
"produces": [
"application/json"
],
"tags": [
- "config"
+ "admin"
],
- "summary": "Reloads any changes in beast global configuration, located at ~/.beast/config.toml.",
+ "summary": "Delete all instances for a challenge",
"parameters": [
{
"type": "string",
- "description": "Bearer",
- "name": "Authorization",
- "in": "header",
+ "description": "Challenge name",
+ "name": "challenge_name",
+ "in": "path",
"required": true
}
],
@@ -218,42 +119,30 @@
"schema": {
"$ref": "#/definitions/api.HTTPPlainResp"
}
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/api.HTTPPlainResp"
- }
}
}
}
},
- "/api/info/challenge/info": {
- "get": {
- "description": "Returns all information about the challenges by the challenge name.",
- "consumes": [
- "application/json"
+ "/api/admin/instances/user/{user_id}": {
+ "delete": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
],
"produces": [
"application/json"
],
"tags": [
- "info"
+ "admin"
],
- "summary": "Returns all information about the challenges.",
+ "summary": "Delete all instances owned by a user",
"parameters": [
{
"type": "string",
- "description": "Bearer",
- "name": "Authorization",
- "in": "header",
- "required": true
- },
- {
- "type": "string",
- "description": "Name of challenge",
- "name": "name",
- "in": "query",
+ "description": "User ID",
+ "name": "user_id",
+ "in": "path",
"required": true
}
],
@@ -261,61 +150,32 @@
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/api.ChallengeInfoResp"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/api.HTTPErrorResp"
- }
- },
- "404": {
- "description": "Not Found",
- "schema": {
- "$ref": "#/definitions/api.HTTPErrorResp"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/api.HTTPErrorResp"
+ "$ref": "#/definitions/api.HTTPPlainResp"
}
}
}
}
},
- "/api/info/challenges": {
+ "/api/admin/instances/{instance_id}": {
"get": {
- "description": "Returns information about all the challenges present in the database with and without filters.",
- "consumes": [
- "application/json"
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
],
"produces": [
"application/json"
],
"tags": [
- "info"
+ "admin"
],
- "summary": "Returns information about all challenges with and without filters.",
+ "summary": "Get an instance by ID",
"parameters": [
{
"type": "string",
- "description": "Filter parameter by which challenges are filtered",
- "name": "filter",
- "in": "query"
- },
- {
- "type": "string",
- "description": "Value of filtered parameter",
- "name": "value",
- "in": "query"
- },
- {
- "type": "string",
- "description": "Bearer",
- "name": "Authorization",
- "in": "header",
+ "description": "Instance ID",
+ "name": "instance_id",
+ "in": "path",
"required": true
}
],
@@ -323,43 +183,30 @@
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/api.ChallengeInfoResp"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/api.HTTPErrorResp"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/api.HTTPErrorResp"
+ "$ref": "#/definitions/api.AdminInstanceResponse"
}
}
}
- }
- },
- "/api/info/images/available": {
- "get": {
- "description": "Returns all the available base images which can be used for challenge creation as the base OS for challenge.",
- "consumes": [
- "application/json"
+ },
+ "delete": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
],
"produces": [
"application/json"
],
"tags": [
- "info"
+ "admin"
],
- "summary": "Gives all the base images that can be used while creating a beast challenge, this is a constant specified in beast global config",
+ "summary": "Delete an instance by ID",
"parameters": [
{
"type": "string",
- "description": "Bearer",
- "name": "Authorization",
- "in": "header",
+ "description": "Instance ID",
+ "name": "instance_id",
+ "in": "path",
"required": true
}
],
@@ -367,15 +214,15 @@
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/api.AvailableImagesResp"
+ "$ref": "#/definitions/api.HTTPPlainResp"
}
}
}
}
},
- "/api/info/logs": {
+ "/api/admin/leaderboard": {
"get": {
- "description": "Gives container logs for a particular challenge, useful for debugging purposes.",
+ "description": "Returns admin leaderboard of all users",
"consumes": [
"application/json"
],
@@ -385,7 +232,7 @@
"tags": [
"info"
],
- "summary": "Handles route related to logs handling of container",
+ "summary": "Returns admin leaderboard",
"parameters": [
{
"type": "string",
@@ -396,8 +243,8 @@
},
{
"type": "string",
- "description": "The name of the challenge to get the logs for.",
- "name": "challenge",
+ "description": "Page number",
+ "name": "page",
"in": "query"
}
],
@@ -405,27 +252,27 @@
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/api.LogsInfoResp"
+ "$ref": "#/definitions/api.UserResp"
}
},
"400": {
"description": "Bad Request",
"schema": {
- "$ref": "#/definitions/api.HTTPPlainResp"
+ "$ref": "#/definitions/api.HTTPErrorResp"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
- "$ref": "#/definitions/api.HTTPPlainResp"
+ "$ref": "#/definitions/api.HTTPErrorResp"
}
}
}
}
},
- "/api/info/ports/used": {
+ "/api/admin/statistics": {
"get": {
- "description": "Returns the ports in use by beast, which cannot be used in creating a new challenge..",
+ "description": "returns statistics of users in competition (currently limited to ban/unban status of users)",
"consumes": [
"application/json"
],
@@ -435,7 +282,7 @@
"tags": [
"info"
],
- "summary": "Returns ports in use by beast by looking in the hack git repository, also returns min and max value of port allowed while specifying in beast challenge config.",
+ "summary": "statistics of users in competition",
"parameters": [
{
"type": "string",
@@ -449,13 +296,25 @@
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/api.PortsInUseResp"
+ "$ref": "#/definitions/api.UsersStatisticsResp"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPErrorResp"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPErrorResp"
}
}
}
}
},
- "/api/info/submissions": {
+ "/api/admin/submissions": {
"get": {
"description": "Handles submissions made by the user",
"consumes": [
@@ -493,9 +352,9 @@
}
}
},
- "/api/info/user": {
- "get": {
- "description": "Returns user info based on userId",
+ "/api/admin/unfreezeLeaderboard": {
+ "post": {
+ "description": "unfreezes the user leaderboard on demand.",
"consumes": [
"application/json"
],
@@ -505,7 +364,7 @@
"tags": [
"info"
],
- "summary": "Returns user info",
+ "summary": "Unfreeze user leaderboard",
"parameters": [
{
"type": "string",
@@ -513,18 +372,6 @@
"name": "Authorization",
"in": "header",
"required": true
- },
- {
- "type": "string",
- "description": "User's id",
- "name": "value",
- "in": "formData"
- },
- {
- "type": "string",
- "description": "username",
- "name": "value",
- "in": "query"
}
],
"responses": {
@@ -549,9 +396,9 @@
}
}
},
- "/api/info/user/available": {
- "get": {
- "description": "Returns all available user's info",
+ "/api/admin/users/{action}/{id}": {
+ "post": {
+ "description": "Ban/Unban/Hide/Unhide a user based on his user id. This operation can only be done by admins",
"consumes": [
"application/json"
],
@@ -559,15 +406,1081 @@
"application/json"
],
"tags": [
- "info"
+ "admin"
+ ],
+ "summary": "Ban/Unban/Hide/Unhide a user based on his id and the action provided.",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Action to perform Ban/Unban/Hide/Unhide",
+ "name": "action",
+ "in": "query",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Id of user",
+ "name": "id",
+ "in": "query",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/api.ChallengeStatusResp"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPPlainResp"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPPlainResp"
+ }
+ }
+ }
+ }
+ },
+ "/api/config/challenge-info": {
+ "post": {
+ "description": "Updates challenge info in the database, located at ~/.beast/beast.db.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "config"
+ ],
+ "summary": "Updates challenge info in the database, located at ~/.beast/beast.db.",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Challenge Name",
+ "name": "name",
+ "in": "formData",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Challenge's description",
+ "name": "desc",
+ "in": "formData"
+ },
+ {
+ "type": "string",
+ "description": "Challenge's points",
+ "name": "points",
+ "in": "formData"
+ },
+ {
+ "type": "string",
+ "description": "Challenge's flag",
+ "name": "flag",
+ "in": "formData"
+ },
+ {
+ "type": "string",
+ "description": "Challenge's tags",
+ "name": "tags",
+ "in": "formData"
+ },
+ {
+ "type": "string",
+ "description": "Challenge's ports",
+ "name": "ports",
+ "in": "formData"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPPlainResp"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPPlainResp"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPErrorResp"
+ }
+ }
+ }
+ }
+ },
+ "/api/info/challenge/{name}": {
+ "get": {
+ "description": "Returns all information about the challenges by the challenge name.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "info"
+ ],
+ "summary": "Returns all information about the challenges.",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Bearer",
+ "name": "Authorization",
+ "in": "header",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Name of challenge",
+ "name": "name",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/api.Challenge"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPErrorResp"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPErrorResp"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPErrorResp"
+ }
+ }
+ }
+ }
+ },
+ "/api/info/challenges": {
+ "get": {
+ "description": "Returns information about all the challenges present in the database with and without filters.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "info"
+ ],
+ "summary": "Returns metadata about all challenges with and without filters.",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Filter parameter by which challenges are filtered",
+ "name": "filter",
+ "in": "query"
+ },
+ {
+ "type": "string",
+ "description": "Value of filtered parameter",
+ "name": "value",
+ "in": "query"
+ },
+ {
+ "type": "string",
+ "description": "Bearer",
+ "name": "Authorization",
+ "in": "header",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/api.ChallengeMetadata"
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPErrorResp"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPErrorResp"
+ }
+ }
+ }
+ }
+ },
+ "/api/info/competition-info": {
+ "get": {
+ "description": "returns various information about the competition which are used to control competition",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "info"
+ ],
+ "summary": "returns competition info",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Bearer",
+ "name": "Authorization",
+ "in": "header",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/api.CompetitionInfoResp"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPErrorResp"
+ }
+ }
+ }
+ }
+ },
+ "/api/info/download": {
+ "get": {
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "info"
+ ],
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Bearer",
+ "name": "Authorization",
+ "in": "header",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "The name of the challenge to get the logs for.",
+ "name": "challenge",
+ "in": "query"
+ },
+ {
+ "type": "string",
+ "description": "The name of the static asset requested.",
+ "name": "asset",
+ "in": "query"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/api.LogsInfoResp"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPPlainResp"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPPlainResp"
+ }
+ }
+ }
+ }
+ },
+ "/api/info/hint/{hintID}": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "info"
+ ],
+ "summary": "Read or purchase a challenge hint",
+ "parameters": [
+ {
+ "type": "integer",
+ "description": "Hint ID",
+ "name": "hintID",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/api.HintResponse"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPErrorResp"
+ }
+ }
+ }
+ },
+ "post": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "info"
+ ],
+ "summary": "Read or purchase a challenge hint",
+ "parameters": [
+ {
+ "type": "integer",
+ "description": "Hint ID",
+ "name": "hintID",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/api.HintResponse"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPErrorResp"
+ }
+ }
+ }
+ }
+ },
+ "/api/info/images/available": {
+ "get": {
+ "description": "Returns all the available base images which can be used for challenge creation as the base OS for challenge.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "info"
+ ],
+ "summary": "Gives all the base images that can be used while creating a beast challenge, this is a constant specified in beast global config",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Bearer",
+ "name": "Authorization",
+ "in": "header",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/api.AvailableImagesResp"
+ }
+ }
+ }
+ }
+ },
+ "/api/info/leaderboard": {
+ "get": {
+ "description": "Returns leaderboard of all users",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "info"
+ ],
+ "summary": "Returns leaderboard",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Bearer",
+ "name": "Authorization",
+ "in": "header",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Page number",
+ "name": "page",
+ "in": "query"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/api.UserResp"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPErrorResp"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPErrorResp"
+ }
+ }
+ }
+ }
+ },
+ "/api/info/leaderboard-graph": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "info"
+ ],
+ "summary": "Return leaderboard score history",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/database.UserLeaderboardResp"
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/info/submissions/challenge/{challenge_id}": {
+ "get": {
+ "description": "Returns all user attempts for a given challenge.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "info"
+ ],
+ "summary": "Get challenge attempts",
+ "parameters": [
+ {
+ "type": "integer",
+ "description": "Challenge ID",
+ "name": "challenge_id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Bearer",
+ "name": "Authorization",
+ "in": "header",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/api.UserSolveResp"
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPErrorResp"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPErrorResp"
+ }
+ }
+ }
+ }
+ },
+ "/api/info/submissions/user/{user_id}": {
+ "get": {
+ "description": "Returns all submissions for a specific user",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "info"
+ ],
+ "summary": "Get submissions by user",
+ "parameters": [
+ {
+ "type": "integer",
+ "description": "User ID",
+ "name": "user_id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Bearer",
+ "name": "Authorization",
+ "in": "header",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/api.SubmissionResp"
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPErrorResp"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPErrorResp"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPErrorResp"
+ }
+ }
+ }
+ }
+ },
+ "/api/info/tags": {
+ "get": {
+ "description": "returns all unique tags",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "info"
+ ],
+ "summary": "returns all tags",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Bearer",
+ "name": "Authorization",
+ "in": "header",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/api.TagInfoResp"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPErrorResp"
+ }
+ }
+ }
+ }
+ },
+ "/api/info/user/{username}": {
+ "get": {
+ "description": "Returns user info based on userId",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "info"
+ ],
+ "summary": "Returns user info",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Bearer",
+ "name": "Authorization",
+ "in": "header",
+ "required": true
+ },
+ {
+ "type": "integer",
+ "description": "User ID",
+ "name": "user_id",
+ "in": "query"
+ },
+ {
+ "type": "string",
+ "description": "Username",
+ "name": "username",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/api.UserResp"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPErrorResp"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPErrorResp"
+ }
+ }
+ }
+ }
+ },
+ "/api/info/usercount": {
+ "get": {
+ "description": "Returns the number of users in the database with role=contestant",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "info"
+ ],
+ "summary": "Returns the number of users in the database with role=contestant",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Bearer",
+ "name": "Authorization",
+ "in": "header",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/api.UserCountResp"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPErrorResp"
+ }
+ }
+ }
+ }
+ },
+ "/api/info/users": {
+ "get": {
+ "description": "Returns all available user's info",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "info"
+ ],
+ "summary": "Returns all user's info",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Bearer",
+ "name": "Authorization",
+ "in": "header",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Sort by username or score",
+ "name": "sort",
+ "in": "query"
+ },
+ {
+ "type": "string",
+ "description": "Score order: asc or desc",
+ "name": "order",
+ "in": "query"
+ },
+ {
+ "type": "string",
+ "description": "Filter by banned, active, or hidden",
+ "name": "filter",
+ "in": "query"
+ },
+ {
+ "type": "string",
+ "description": "Response format: json or csv",
+ "name": "format",
+ "in": "query"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/api.UsersResp"
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPErrorResp"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPErrorResp"
+ }
+ }
+ }
+ }
+ },
+ "/api/instances": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "instances"
+ ],
+ "summary": "List the current user's instances",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/api.InstanceResponse"
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/instances/{challenge_name}": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "instances"
+ ],
+ "summary": "Get the current user's challenge instance",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Challenge name",
+ "name": "challenge_name",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/api.InstanceResponse"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPErrorResp"
+ }
+ }
+ }
+ },
+ "delete": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "instances"
+ ],
+ "summary": "Delete the current user's challenge instance",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Challenge name",
+ "name": "challenge_name",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPPlainResp"
+ }
+ }
+ }
+ }
+ },
+ "/api/instances/{challenge_name}/extend": {
+ "post": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "instances"
+ ],
+ "summary": "Extend the current user's challenge instance",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Challenge name",
+ "name": "challenge_name",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "integer",
+ "description": "Requested extension in seconds",
+ "name": "seconds",
+ "in": "formData"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/api.InstanceResponse"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPErrorResp"
+ }
+ }
+ }
+ }
+ },
+ "/api/instances/{challenge_name}/spawn": {
+ "post": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "instances"
+ ],
+ "summary": "Spawn a per-user challenge instance",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Challenge name",
+ "name": "challenge_name",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/api.InstanceResponse"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPErrorResp"
+ }
+ }
+ }
+ }
+ },
+ "/api/manage/challenge/": {
+ "post": {
+ "description": "Handles challenge management routes with actions which includes - DEPLOY, UNDEPLOY, PURGE.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "manage"
+ ],
+ "summary": "Handles challenge management actions.",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Name of the challenge to be managed, here name is the unique identifier for challenge",
+ "name": "name",
+ "in": "query",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Action for the challenge",
+ "name": "action",
+ "in": "query",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPPlainResp"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPPlainResp"
+ }
+ }
+ }
+ }
+ },
+ "/api/manage/challenge/multiple/": {
+ "post": {
+ "description": "Handles challenge management routes with actions which includes - DEPLOY, UNDEPLOY, PURGE of multiple challenges.",
+ "consumes": [
+ "application/json"
],
- "summary": "Returns all user's info",
+ "produces": [
+ "application/json"
+ ],
+ "summary": "Handles multiple challenge management actions.",
"parameters": [
{
"type": "string",
- "description": "Bearer",
- "name": "Authorization",
- "in": "header",
+ "description": "Name of the challenge to be managed, here name is the unique identifier for challenges seperated by a comma",
+ "name": "name",
+ "in": "query",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Action for the challenge",
+ "name": "action",
+ "in": "query",
"required": true
}
],
@@ -575,11 +1488,49 @@
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/api.UserResp"
+ "$ref": "#/definitions/api.HTTPPlainResp"
}
},
- "404": {
- "description": "Not Found",
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPPlainResp"
+ }
+ }
+ }
+ }
+ },
+ "/api/manage/challenge/upload": {
+ "post": {
+ "description": "Handles the challenge management from a challenge in zip file. Currently prepare the zip file",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "manage"
+ ],
+ "summary": "Unzip and fetch info from beast.toml file in challenge",
+ "parameters": [
+ {
+ "type": "file",
+ "description": ".zip file to be uploaded to fetch challenge info",
+ "name": "file",
+ "in": "formData",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/api.ChallengePreviewResp"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
"schema": {
"$ref": "#/definitions/api.HTTPErrorResp"
}
@@ -593,11 +1544,12 @@
}
}
},
- "/api/manage/challenge/": {
+ "/api/manage/challenge/validateflag": {
"post": {
- "description": "Handles challenge management routes with actions which includes - DEPLOY, UNDEPLOY, PURGE.",
- "consumes": [
- "application/json"
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
],
"produces": [
"application/json"
@@ -605,19 +1557,57 @@
"tags": [
"manage"
],
- "summary": "Handles challenge management actions.",
+ "summary": "Validate a configured challenge flag as its manager",
"parameters": [
{
"type": "string",
- "description": "Name of the challenge to be managed, here name is the unique identifier for challenge",
- "name": "name",
- "in": "query",
+ "description": "Challenge name",
+ "name": "challenge_name",
+ "in": "formData",
"required": true
},
{
"type": "string",
- "description": "Action for the challenge",
- "name": "action",
+ "description": "Flag to validate",
+ "name": "flag",
+ "in": "formData",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPPlainResp"
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPErrorResp"
+ }
+ }
+ }
+ }
+ },
+ "/api/manage/challenge/verify": {
+ "post": {
+ "description": "Commits the challenge container for later use",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "manage"
+ ],
+ "summary": "Commits the challenge container so that later the challenge image can be used deployment",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Name of the challenge to commit",
+ "name": "challenge",
"in": "query",
"required": true
}
@@ -629,8 +1619,8 @@
"$ref": "#/definitions/api.HTTPPlainResp"
}
},
- "400": {
- "description": "Bad Request",
+ "500": {
+ "description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/api.HTTPPlainResp"
}
@@ -638,9 +1628,13 @@
}
}
},
- "/api/manage/challenge/upload": {
+ "/api/manage/challenge/{name}/exec": {
"post": {
- "description": "Handles the challenge management from a challenge in tar file. Currently prepare the tar file",
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
"consumes": [
"application/json"
],
@@ -650,21 +1644,30 @@
"tags": [
"manage"
],
- "summary": "Untar and fetch info from beast.toml file in challenge",
+ "summary": "Execute an argument-vector command in an owned challenge container",
"parameters": [
{
- "type": "file",
- "description": ".tar file to be uploaded to fetch challenge info",
- "name": "file",
- "in": "formData",
+ "type": "string",
+ "description": "Challenge name",
+ "name": "name",
+ "in": "path",
"required": true
+ },
+ {
+ "description": "Bounded exec request",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/api.ExecChallengeRequest"
+ }
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/api.ChallengePreviewResp"
+ "$ref": "#/definitions/api.ExecChallengeResponse"
}
},
"400": {
@@ -673,8 +1676,8 @@
"$ref": "#/definitions/api.HTTPErrorResp"
}
},
- "500": {
- "description": "Internal Server Error",
+ "403": {
+ "description": "Forbidden",
"schema": {
"$ref": "#/definitions/api.HTTPErrorResp"
}
@@ -714,7 +1717,7 @@
}
}
},
- "/api/manage/deploy/local": {
+ "/api/manage/deploy/local/": {
"post": {
"description": "Handles deployment of a challenge using the absolute directory path",
"consumes": [
@@ -758,7 +1761,62 @@
}
}
},
- "/api/manage/multiple/:action": {
+ "/api/manage/logs": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Gives container logs for a particular challenge, useful for debugging purposes.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "info"
+ ],
+ "summary": "Handles route related to logs handling of container",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Bearer",
+ "name": "Authorization",
+ "in": "header",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "The name of the challenge to get the logs for.",
+ "name": "challenge",
+ "in": "query"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/api.LogsInfoResp"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPPlainResp"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPPlainResp"
+ }
+ }
+ }
+ }
+ },
+ "/api/manage/multiple/{action}": {
"post": {
"description": "Handles challenge management routes for multiple the challenges with actions which includes - DEPLOY, UNDEPLOY.",
"consumes": [
@@ -802,7 +1860,7 @@
}
}
},
- "/api/manage/schedule/:action": {
+ "/api/manage/schedule/{action}": {
"post": {
"description": "Handles scheduleing of challenge action to executed at some later point of time",
"consumes": [
@@ -871,7 +1929,7 @@
}
}
},
- "/api/manage/static/:action": {
+ "/api/manage/static/{action}": {
"post": {
"description": "Handles beast static content serving container routes.",
"consumes": [
@@ -961,7 +2019,7 @@
}
},
"/api/notification/available": {
- "post": {
+ "get": {
"description": "Fetch all the notifications from database",
"consumes": [
"application/json"
@@ -990,7 +2048,7 @@
}
},
"/api/notification/delete": {
- "post": {
+ "delete": {
"description": "Removes notifications",
"consumes": [
"application/json"
@@ -1033,8 +2091,32 @@
}
}
},
+ "/api/notification/stream": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "produces": [
+ "text/event-stream"
+ ],
+ "tags": [
+ "notification"
+ ],
+ "summary": "Stream server-sent notifications",
+ "responses": {
+ "200": {
+ "description": "SSE stream",
+ "schema": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
"/api/notification/update": {
- "post": {
+ "put": {
"description": "Updates any changes in the notifications",
"consumes": [
"application/json"
@@ -1091,7 +2173,7 @@
}
}
},
- "/api/remote/reset/": {
+ "/api/remote/reset": {
"post": {
"description": "Resets local copy of remote git directory, it first deletes the existing directory and then clone from the remote again.",
"consumes": [
@@ -1129,7 +2211,7 @@
}
}
},
- "/api/remote/sync/": {
+ "/api/remote/sync": {
"post": {
"description": "Syncs beasts local challenges database with the remote git repository(hack) the local copy of the challenge database is located at $HOME/.beast/remote/$REMOTE_NAME.",
"consumes": [
@@ -1167,7 +2249,61 @@
}
}
},
- "/api/status/all/:filter": {
+ "/api/status/all": {
+ "get": {
+ "description": "This returns the challenges in the status provided, along with their name and last updated time.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "status"
+ ],
+ "summary": "Returns challenge deployment status from the beast database for the challenges which matches the stauts according to filter.",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Status type to filter with, if none specified then all",
+ "name": "filter",
+ "in": "query",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Bearer",
+ "name": "Authorization",
+ "in": "header",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/api.ChallengeStatusResp"
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPPlainResp"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPPlainResp"
+ }
+ }
+ }
+ }
+ },
+ "/api/status/all/{filter}": {
"get": {
"description": "This returns the challenges in the status provided, along with their name and last updated time.",
"consumes": [
@@ -1221,7 +2357,7 @@
}
}
},
- "/api/status/challenge/:name": {
+ "/api/status/challenge/{name}": {
"get": {
"description": "Returns challenge deployment status from the beast database, for those challenges which are not present a status value NA is returned.",
"consumes": [
@@ -1423,12 +2559,6 @@
"name": "email",
"in": "formData",
"required": true
- },
- {
- "type": "string",
- "description": "User's ssh-key",
- "name": "ssh-key",
- "in": "formData"
}
],
"responses": {
@@ -1493,9 +2623,209 @@
}
}
}
+ },
+ "/auth/send-otp": {
+ "post": {
+ "consumes": [
+ "multipart/form-data"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "auth"
+ ],
+ "summary": "Send an email verification OTP",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Email address",
+ "name": "email",
+ "in": "formData",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPPlainResp"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPErrorResp"
+ }
+ }
+ }
+ }
+ },
+ "/auth/send-otp-forget": {
+ "post": {
+ "consumes": [
+ "multipart/form-data"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "auth"
+ ],
+ "summary": "Send a password-reset OTP",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Registered email address",
+ "name": "email",
+ "in": "formData",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPPlainResp"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPErrorResp"
+ }
+ }
+ }
+ }
+ },
+ "/auth/verify-otp": {
+ "post": {
+ "consumes": [
+ "multipart/form-data"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "auth"
+ ],
+ "summary": "Verify an email OTP",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Email address",
+ "name": "email",
+ "in": "formData",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "One-time code",
+ "name": "otp",
+ "in": "formData",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPPlainResp"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPErrorResp"
+ }
+ }
+ }
+ }
+ },
+ "/auth/verify-otp-forget": {
+ "post": {
+ "consumes": [
+ "multipart/form-data"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "auth"
+ ],
+ "summary": "Verify a password-reset OTP",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Registered email address",
+ "name": "email",
+ "in": "formData",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "One-time code",
+ "name": "otp",
+ "in": "formData",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPAuthorizeResp"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/api.HTTPErrorResp"
+ }
+ }
+ }
+ }
}
},
"definitions": {
+ "api.AdminInstanceResponse": {
+ "type": "object",
+ "properties": {
+ "challenge_name": {
+ "type": "string"
+ },
+ "container_id": {
+ "type": "string"
+ },
+ "created_at": {
+ "type": "string"
+ },
+ "deployment_type": {
+ "type": "string"
+ },
+ "expires_at": {
+ "type": "string"
+ },
+ "hosted_address": {
+ "type": "string"
+ },
+ "instance_id": {
+ "type": "string"
+ },
+ "port": {
+ "type": "integer"
+ },
+ "ttl_seconds": {
+ "type": "integer"
+ },
+ "user_id": {
+ "type": "string"
+ },
+ "username": {
+ "type": "string"
+ }
+ }
+ },
"api.AvailableImagesResp": {
"type": "object",
"properties": {
@@ -1505,8 +2835,8 @@
"type": "string"
},
"example": [
- "['ubuntu16.04'",
- " 'ubuntu18.04']"
+ "ubuntu:24.04",
+ "debian:bookworm"
]
},
"message": {
@@ -1515,9 +2845,29 @@
}
}
},
- "api.ChallengeInfoResp": {
+ "api.Challenge": {
"type": "object",
"properties": {
+ "additionalLinks": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "example": [
+ "https://link1.example",
+ "https://link2.example"
+ ]
+ },
+ "assets": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "example": [
+ "image1.png",
+ "zippy.zip"
+ ]
+ },
"category": {
"type": "string",
"example": "web"
@@ -1525,14 +2875,111 @@
"createdAt": {
"type": "string"
},
+ "deployedLink": {
+ "type": "string",
+ "example": "beast.sdslabs.co or ip:port"
+ },
+ "deployedStatus": {
+ "type": "string",
+ "example": "deployed"
+ },
"description": {
- "type": "string"
+ "type": "string",
+ "example": "A simple web challenge"
+ },
+ "difficulty": {
+ "type": "string",
+ "example": "easy"
},
"hints": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/api.HintInfo"
+ }
+ },
+ "id": {
+ "type": "integer",
+ "example": 0
+ },
+ "instanceExpiration": {
+ "type": "integer",
+ "example": 300
+ },
+ "instanced": {
+ "type": "boolean",
+ "example": false
+ },
+ "maxAttemptLimit": {
+ "type": "integer",
+ "example": 5
+ },
+ "name": {
+ "type": "string",
+ "example": "Web Challenge"
+ },
+ "points": {
+ "type": "integer",
+ "example": 50
+ },
+ "preRequisite": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "example": [
+ "chall1",
+ "chall2"
+ ]
+ },
+ "previousTries": {
+ "type": "integer",
+ "example": 3
+ },
+ "solveStatus": {
+ "type": "boolean",
+ "example": true
+ },
+ "solvesNumber": {
+ "type": "integer",
+ "example": 100
+ },
+ "tags": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "example": [
+ "pwn",
+ "misc"
+ ]
+ }
+ }
+ },
+ "api.ChallengeMetadata": {
+ "type": "object",
+ "properties": {
+ "createdAt": {
"type": "string"
},
+ "deployedStatus": {
+ "type": "string",
+ "example": "deployed"
+ },
+ "difficulty": {
+ "type": "string",
+ "example": "easy"
+ },
"id": {
- "type": "integer"
+ "type": "integer",
+ "example": 0
+ },
+ "instanceExpiration": {
+ "type": "integer",
+ "example": 300
+ },
+ "instanced": {
+ "type": "boolean",
+ "example": false
},
"name": {
"type": "string",
@@ -1542,43 +2989,74 @@
"type": "integer",
"example": 50
},
- "ports": {
+ "preRequisite": {
"type": "array",
"items": {
- "type": "integer"
- }
+ "type": "string"
+ },
+ "example": [
+ "chall1",
+ "chall2"
+ ]
},
- "solves": {
- "type": "array",
- "items": {
- "$ref": "#/definitions/api.UserSolveResp"
- }
+ "solveStatus": {
+ "type": "boolean",
+ "example": true
},
"solvesNumber": {
"type": "integer",
"example": 100
},
- "status": {
- "type": "string",
- "example": "deployed"
+ "tags": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "example": [
+ "pwn",
+ "misc"
+ ]
}
}
},
"api.ChallengePreviewResp": {
"type": "object",
"properties": {
+ "additionalLinks": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "example": [
+ "https://link1.example",
+ "https://link2.example"
+ ]
+ },
+ "assets": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "example": [
+ "image1.png",
+ "zippy.zip"
+ ]
+ },
"category": {
"type": "string",
"example": "web"
},
+ "deployedLink": {
+ "type": "string",
+ "example": "beast.sdslabs.co"
+ },
"description": {
- "type": "string"
+ "type": "string",
+ "example": "A simple web challenge"
},
- "hints": {
- "type": "array",
- "items": {
- "type": "string"
- }
+ "maxAttemptLimit": {
+ "type": "integer",
+ "example": 5
},
"name": {
"type": "string",
@@ -1592,7 +3070,31 @@
"type": "array",
"items": {
"type": "integer"
- }
+ },
+ "example": [
+ 3001,
+ 3002
+ ]
+ },
+ "preRequisite": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "example": [
+ "web-php",
+ "simple"
+ ]
+ },
+ "tags": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "example": [
+ "pwn",
+ "misc"
+ ]
}
}
},
@@ -1601,7 +3103,7 @@
"properties": {
"category": {
"type": "string",
- "example": "web"
+ "example": "bare"
},
"id": {
"type": "integer",
@@ -1617,6 +3119,16 @@
},
"solvedAt": {
"type": "string"
+ },
+ "tags": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "example": [
+ "pwn",
+ "misc"
+ ]
}
}
},
@@ -1662,7 +3174,42 @@
"type": "string"
},
"timezone": {
+ "type": "string",
+ "example": "Asia/Calcutta: UTC +05:30"
+ }
+ }
+ },
+ "api.ExecChallengeRequest": {
+ "type": "object",
+ "properties": {
+ "command": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "instance_id": {
+ "type": "string"
+ },
+ "timeout_seconds": {
+ "type": "integer"
+ }
+ }
+ },
+ "api.ExecChallengeResponse": {
+ "type": "object",
+ "properties": {
+ "exit_code": {
+ "type": "integer"
+ },
+ "stderr": {
+ "type": "string"
+ },
+ "stdout": {
"type": "string"
+ },
+ "truncated": {
+ "type": "boolean"
}
}
},
@@ -1701,60 +3248,95 @@
}
}
},
- "api.LogsInfoResp": {
+ "api.HintInfo": {
"type": "object",
"properties": {
- "stderr": {
- "type": "string",
- "example": "[ERROR] Challenge deployment failed."
+ "id": {
+ "type": "integer"
},
- "stdout": {
- "type": "string",
- "example": "[INFO] Challenge is starting to deploy"
+ "points": {
+ "type": "integer"
}
}
},
- "api.PortsInUseResp": {
+ "api.HintResponse": {
"type": "object",
"properties": {
- "port_max_value": {
- "type": "integer",
- "example": 20000
+ "description": {
+ "type": "string",
+ "example": "This is a hint"
},
- "port_min_value": {
+ "points": {
"type": "integer",
- "example": 10000
+ "example": 10
+ }
+ }
+ },
+ "api.InstanceResponse": {
+ "type": "object",
+ "properties": {
+ "challenge_name": {
+ "type": "string"
},
- "ports_in_use": {
- "type": "array",
- "items": {
- "type": "integer"
- }
+ "created_at": {
+ "type": "string"
+ },
+ "expires_at": {
+ "type": "string"
+ },
+ "hosted_address": {
+ "type": "string"
+ },
+ "instance_id": {
+ "type": "string"
+ },
+ "port": {
+ "type": "integer"
+ },
+ "ttl_seconds": {
+ "type": "integer"
}
}
},
- "api.SubmissionResp": {
+ "api.LogsInfoResp": {
"type": "object",
"properties": {
- "category": {
+ "stderr": {
"type": "string",
- "example": "web"
+ "example": "[ERROR] Challenge deployment failed."
},
+ "stdout": {
+ "type": "string",
+ "example": "[INFO] Challenge is starting to deploy"
+ }
+ }
+ },
+ "api.SubmissionResp": {
+ "type": "object",
+ "properties": {
"chall_id": {
"type": "integer",
"example": 3
},
+ "cheating": {
+ "type": "boolean",
+ "example": false
+ },
+ "flag": {
+ "type": "string",
+ "example": "flag{@#$}"
+ },
"name": {
"type": "string",
"example": "Web Challenge"
},
- "points": {
- "type": "integer",
- "example": 50
- },
"solvedAt": {
"type": "string"
},
+ "success": {
+ "type": "boolean",
+ "example": true
+ },
"user_id": {
"type": "integer",
"example": 3
@@ -1762,10 +3344,25 @@
"username": {
"type": "string",
"example": "fristonio"
- },
- "flag": {
- "type": "string",
- "example": "flag{@#$}"
+ }
+ }
+ },
+ "api.TagInfoResp": {
+ "type": "object",
+ "properties": {
+ "tags": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "api.UserCountResp": {
+ "type": "object",
+ "properties": {
+ "user_count": {
+ "type": "integer"
}
}
},
@@ -1811,6 +3408,14 @@
"api.UserSolveResp": {
"type": "object",
"properties": {
+ "correct": {
+ "type": "boolean",
+ "example": true
+ },
+ "flag": {
+ "type": "string",
+ "example": "flag{example_flag}"
+ },
"id": {
"type": "integer",
"example": 5
@@ -1824,18 +3429,93 @@
}
}
},
+ "api.UsersResp": {
+ "type": "object",
+ "properties": {
+ "email": {
+ "type": "string",
+ "example": "fristonio@gmail.com"
+ },
+ "id": {
+ "type": "integer",
+ "example": 5
+ },
+ "rank": {
+ "type": "integer",
+ "example": 15
+ },
+ "role": {
+ "type": "string",
+ "example": "author"
+ },
+ "score": {
+ "type": "integer",
+ "example": 750
+ },
+ "status": {
+ "type": "integer",
+ "example": 0
+ },
+ "username": {
+ "type": "string",
+ "example": "CTF is live now!"
+ }
+ }
+ },
"api.UsersStatisticsResp": {
"type": "object",
"properties": {
"banned_users": {
- "type": "integer"
+ "type": "integer",
+ "example": 60
},
"total_registered_users": {
"type": "integer",
"example": 120
},
"unbanned_users": {
- "type": "integer"
+ "type": "integer",
+ "example": 60
+ }
+ }
+ },
+ "database.TimeSeries": {
+ "type": "object",
+ "properties": {
+ "score": {
+ "type": "integer",
+ "example": 750
+ },
+ "timestamp": {
+ "type": "string",
+ "example": "2018-12-31T22:20:08"
+ }
+ }
+ },
+ "database.UserLeaderboardResp": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "integer",
+ "example": 5
+ },
+ "rank": {
+ "type": "integer",
+ "example": 15
+ },
+ "score": {
+ "type": "integer",
+ "example": 750
+ },
+ "timeSeriesData": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/database.TimeSeries"
+ }
+ },
+ "username": {
+ "type": "string",
+ "example": "ABCD"
}
}
}
diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml
index f81d0b2e..d4c3626a 100644
--- a/api/docs/swagger.yaml
+++ b/api/docs/swagger.yaml
@@ -1,11 +1,36 @@
basePath: /
definitions:
+ api.AdminInstanceResponse:
+ properties:
+ challenge_name:
+ type: string
+ container_id:
+ type: string
+ created_at:
+ type: string
+ deployment_type:
+ type: string
+ expires_at:
+ type: string
+ hosted_address:
+ type: string
+ instance_id:
+ type: string
+ port:
+ type: integer
+ ttl_seconds:
+ type: integer
+ user_id:
+ type: string
+ username:
+ type: string
+ type: object
api.AvailableImagesResp:
properties:
images:
example:
- - '[''ubuntu16.04'''
- - ' ''ubuntu18.04'']'
+ - ubuntu:24.04
+ - debian:bookworm
items:
type: string
type: array
@@ -13,18 +38,54 @@ definitions:
example: Available Base images.
type: string
type: object
- api.ChallengeInfoResp:
+ api.Challenge:
properties:
+ additionalLinks:
+ example:
+ - https://link1.example
+ - https://link2.example
+ items:
+ type: string
+ type: array
+ assets:
+ example:
+ - image1.png
+ - zippy.zip
+ items:
+ type: string
+ type: array
category:
example: web
type: string
createdAt:
type: string
+ deployedLink:
+ example: beast.sdslabs.co or ip:port
+ type: string
+ deployedStatus:
+ example: deployed
+ type: string
description:
+ example: A simple web challenge
type: string
- hints:
+ difficulty:
+ example: easy
type: string
+ hints:
+ items:
+ $ref: '#/definitions/api.HintInfo'
+ type: array
id:
+ example: 0
+ type: integer
+ instanceExpiration:
+ example: 300
+ type: integer
+ instanced:
+ example: false
+ type: boolean
+ maxAttemptLimit:
+ example: 5
type: integer
name:
example: Web Challenge
@@ -32,32 +93,104 @@ definitions:
points:
example: 50
type: integer
- ports:
- items:
- type: integer
- type: array
- solves:
+ preRequisite:
+ example:
+ - chall1
+ - chall2
items:
- $ref: '#/definitions/api.UserSolveResp'
+ type: string
type: array
+ previousTries:
+ example: 3
+ type: integer
+ solveStatus:
+ example: true
+ type: boolean
solvesNumber:
example: 100
type: integer
- status:
+ tags:
+ example:
+ - pwn
+ - misc
+ items:
+ type: string
+ type: array
+ type: object
+ api.ChallengeMetadata:
+ properties:
+ createdAt:
+ type: string
+ deployedStatus:
example: deployed
type: string
+ difficulty:
+ example: easy
+ type: string
+ id:
+ example: 0
+ type: integer
+ instanceExpiration:
+ example: 300
+ type: integer
+ instanced:
+ example: false
+ type: boolean
+ name:
+ example: Web Challenge
+ type: string
+ points:
+ example: 50
+ type: integer
+ preRequisite:
+ example:
+ - chall1
+ - chall2
+ items:
+ type: string
+ type: array
+ solveStatus:
+ example: true
+ type: boolean
+ solvesNumber:
+ example: 100
+ type: integer
+ tags:
+ example:
+ - pwn
+ - misc
+ items:
+ type: string
+ type: array
type: object
api.ChallengePreviewResp:
properties:
+ additionalLinks:
+ example:
+ - https://link1.example
+ - https://link2.example
+ items:
+ type: string
+ type: array
+ assets:
+ example:
+ - image1.png
+ - zippy.zip
+ items:
+ type: string
+ type: array
category:
example: web
type: string
+ deployedLink:
+ example: beast.sdslabs.co
+ type: string
description:
+ example: A simple web challenge
type: string
- hints:
- items:
- type: string
- type: array
+ maxAttemptLimit:
+ example: 5
+ type: integer
name:
example: Web Challenge
type: string
@@ -65,14 +198,31 @@ definitions:
example: 50
type: integer
ports:
+ example:
+ - 3001
+ - 3002
items:
type: integer
type: array
+ preRequisite:
+ example:
+ - web-php
+ - simple
+ items:
+ type: string
+ type: array
+ tags:
+ example:
+ - pwn
+ - misc
+ items:
+ type: string
+ type: array
type: object
api.ChallengeSolveResp:
properties:
category:
- example: web
+ example: bare
type: string
id:
example: 4
@@ -85,6 +235,13 @@ definitions:
type: integer
solvedAt:
type: string
+ tags:
+ example:
+ - pwn
+ - misc
+ items:
+ type: string
+ type: array
type: object
api.ChallengeStatusResp:
properties:
@@ -116,7 +273,30 @@ definitions:
starting_time:
type: string
timezone:
+ example: 'Asia/Calcutta: UTC +05:30'
+ type: string
+ type: object
+ api.ExecChallengeRequest:
+ properties:
+ command:
+ items:
+ type: string
+ type: array
+ instance_id:
type: string
+ timeout_seconds:
+ type: integer
+ type: object
+ api.ExecChallengeResponse:
+ properties:
+ exit_code:
+ type: integer
+ stderr:
+ type: string
+ stdout:
+ type: string
+ truncated:
+ type: boolean
type: object
api.HTTPAuthorizeResp:
properties:
@@ -142,6 +322,39 @@ definitions:
example: Messsage in response to your request
type: string
type: object
+ api.HintInfo:
+ properties:
+ id:
+ type: integer
+ points:
+ type: integer
+ type: object
+ api.HintResponse:
+ properties:
+ description:
+ example: This is a hint
+ type: string
+ points:
+ example: 10
+ type: integer
+ type: object
+ api.InstanceResponse:
+ properties:
+ challenge_name:
+ type: string
+ created_at:
+ type: string
+ expires_at:
+ type: string
+ hosted_address:
+ type: string
+ instance_id:
+ type: string
+ port:
+ type: integer
+ ttl_seconds:
+ type: integer
+ type: object
api.LogsInfoResp:
properties:
stderr:
@@ -151,44 +364,43 @@ definitions:
example: '[INFO] Challenge is starting to deploy'
type: string
type: object
- api.PortsInUseResp:
- properties:
- port_max_value:
- example: 20000
- type: integer
- port_min_value:
- example: 10000
- type: integer
- ports_in_use:
- items:
- type: integer
- type: array
- type: object
api.SubmissionResp:
properties:
- category:
- example: web
- type: string
chall_id:
example: 3
type: integer
+ cheating:
+ example: false
+ type: boolean
+ flag:
+ example: flag{@#$}
+ type: string
name:
example: Web Challenge
type: string
- points:
- example: 50
- type: integer
solvedAt:
type: string
+ success:
+ example: true
+ type: boolean
user_id:
example: 3
type: integer
username:
example: fristonio
type: string
- flag:
- example: flag{@#$}
- type: string
+ type: object
+ api.TagInfoResp:
+ properties:
+ tags:
+ items:
+ type: string
+ type: array
+ type: object
+ api.UserCountResp:
+ properties:
+ user_count:
+ type: integer
type: object
api.UserResp:
properties:
@@ -220,6 +432,12 @@ definitions:
type: object
api.UserSolveResp:
properties:
+ correct:
+ example: true
+ type: boolean
+ flag:
+ example: flag{example_flag}
+ type: string
id:
example: 5
type: integer
@@ -229,35 +447,88 @@ definitions:
example: fristonio
type: string
type: object
+ api.UsersResp:
+ properties:
+ email:
+ example: fristonio@gmail.com
+ type: string
+ id:
+ example: 5
+ type: integer
+ rank:
+ example: 15
+ type: integer
+ role:
+ example: author
+ type: string
+ score:
+ example: 750
+ type: integer
+ status:
+ example: 0
+ type: integer
+ username:
+ example: CTF is live now!
+ type: string
+ type: object
api.UsersStatisticsResp:
properties:
banned_users:
+ example: 60
type: integer
total_registered_users:
example: 120
type: integer
unbanned_users:
+ example: 60
+ type: integer
+ type: object
+ database.TimeSeries:
+ properties:
+ score:
+ example: 750
+ type: integer
+ timestamp:
+ example: 2018-12-31T22:20:08
+ type: string
+ type: object
+ database.UserLeaderboardResp:
+ properties:
+ id:
+ example: 5
+ type: integer
+ rank:
+ example: 15
type: integer
+ score:
+ example: 750
+ type: integer
+ timeSeriesData:
+ items:
+ $ref: '#/definitions/database.TimeSeries'
+ type: array
+ username:
+ example: ABCD
+ type: string
type: object
-host: beast.sdslabs.co
info:
contact:
- email: contact.sdslabs.co.in
+ email: contact@sdslabs.co.in
name: SDSLabs
url: https://chat.sdslabs.co
- description: Beast the automatic deployment tool for backdoor
+ description: Authenticated API for Beast CTF challenge deployment and competition
+ services.
license:
name: Apache 2.0
- url: http://www.apache.org/licenses/LICENSE-2.0.html
+ url: https://www.apache.org/licenses/LICENSE-2.0.html
title: Beast API
- version: "1.0"
+ version: "0.2"
paths:
- /api/admin/statistics:
- get:
+ /api/admin/freezeLeaderboard:
+ post:
consumes:
- application/json
- description: returns various information about the competition which are used
- to control competition
+ description: freezes the user leaderboard on demand.
parameters:
- description: Bearer
in: header
@@ -270,29 +541,40 @@ paths:
"200":
description: OK
schema:
- $ref: '#/definitions/api.CompetitionInfoResp'
+ $ref: '#/definitions/api.UserResp'
"400":
description: Bad Request
schema:
$ref: '#/definitions/api.HTTPErrorResp'
- summary: returns competition info
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/api.HTTPErrorResp'
+ summary: Freeze user leaderboard
tags:
- info
- /api/admin/users/:action/:id:
- post:
- consumes:
+ /api/admin/instances:
+ get:
+ produces:
- application/json
- description: Ban/unban a user based on his user id. This operation can only
- be done by admins
+ responses:
+ "200":
+ description: OK
+ schema:
+ items:
+ $ref: '#/definitions/api.AdminInstanceResponse'
+ type: array
+ security:
+ - ApiKeyAuth: []
+ summary: List all active instances
+ tags:
+ - admin
+ /api/admin/instances/{instance_id}:
+ delete:
parameters:
- - description: Action to perform ban/unban
- in: query
- name: action
- required: true
- type: string
- - description: Id of user
- in: query
- name: id
+ - description: Instance ID
+ in: path
+ name: instance_id
required: true
type: string
produces:
@@ -300,82 +582,140 @@ paths:
responses:
"200":
description: OK
- schema:
- $ref: '#/definitions/api.ChallengeStatusResp'
- "400":
- description: Bad Request
- schema:
- $ref: '#/definitions/api.HTTPPlainResp'
- "500":
- description: Internal Server Error
schema:
$ref: '#/definitions/api.HTTPPlainResp'
- summary: Ban/Unban a user based on his id and the action provided.
+ security:
+ - ApiKeyAuth: []
+ summary: Delete an instance by ID
tags:
- admin
- /api/config/competition-info:
- post:
- consumes:
- - application/json
- description: Populates beast gobal config map by reparsing the config file $HOME/.beast/config.toml.
+ get:
parameters:
- - description: Competition Name
- in: formData
- name: name
+ - description: Instance ID
+ in: path
+ name: instance_id
required: true
type: string
- - description: Some information about competition
- in: formData
- name: about
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/api.AdminInstanceResponse'
+ security:
+ - ApiKeyAuth: []
+ summary: Get an instance by ID
+ tags:
+ - admin
+ /api/admin/instances/challenge/{challenge_name}:
+ delete:
+ parameters:
+ - description: Challenge name
+ in: path
+ name: challenge_name
required: true
type: string
- - description: Competitions Prizes for the winners
- in: formData
- name: prizes
- type: string
- - description: Competition's starting time
- in: formData
- name: starting_time
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/api.HTTPPlainResp'
+ security:
+ - ApiKeyAuth: []
+ summary: Delete all instances for a challenge
+ tags:
+ - admin
+ /api/admin/instances/user/{user_id}:
+ delete:
+ parameters:
+ - description: User ID
+ in: path
+ name: user_id
required: true
type: string
- - description: Competition's ending time
- in: formData
- name: ending_time
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/api.HTTPPlainResp'
+ security:
+ - ApiKeyAuth: []
+ summary: Delete all instances owned by a user
+ tags:
+ - admin
+ /api/admin/leaderboard:
+ get:
+ consumes:
+ - application/json
+ description: Returns admin leaderboard of all users
+ parameters:
+ - description: Bearer
+ in: header
+ name: Authorization
required: true
type: string
- - description: Competition's timezone
- in: formData
- name: timezone
- required: true
+ - description: Page number
+ in: query
+ name: page
type: string
- - description: Competition's logo
- in: formData
- name: logo
- type: file
produces:
- application/json
responses:
"200":
description: OK
schema:
- $ref: '#/definitions/api.HTTPPlainResp'
+ $ref: '#/definitions/api.UserResp'
"400":
description: Bad Request
schema:
- $ref: '#/definitions/api.HTTPPlainResp'
+ $ref: '#/definitions/api.HTTPErrorResp'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/api.HTTPErrorResp'
- summary: Updates competition info in the beast global configuration, located
- at ~/.beast/config.toml.
+ summary: Returns admin leaderboard
tags:
- - config
- /api/config/reload/:
- patch:
+ - info
+ /api/admin/statistics:
+ get:
+ consumes:
+ - application/json
+ description: returns statistics of users in competition (currently limited to
+ ban/unban status of users)
+ parameters:
+ - description: Bearer
+ in: header
+ name: Authorization
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/api.UsersStatisticsResp'
+ "404":
+ description: Not Found
+ schema:
+ $ref: '#/definitions/api.HTTPErrorResp'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/api.HTTPErrorResp'
+ summary: statistics of users in competition
+ tags:
+ - info
+ /api/admin/submissions:
+ get:
consumes:
- application/json
- description: Populates beast gobal config map by reparsing the config file $HOME/.beast/config.toml.
+ description: Handles submissions made by the user
parameters:
- description: Bearer
in: header
@@ -384,6 +724,115 @@ paths:
type: string
produces:
- application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/api.SubmissionResp'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/api.HTTPErrorResp'
+ summary: Handles submissions made by the user
+ tags:
+ - info
+ /api/admin/unfreezeLeaderboard:
+ post:
+ consumes:
+ - application/json
+ description: unfreezes the user leaderboard on demand.
+ parameters:
+ - description: Bearer
+ in: header
+ name: Authorization
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/api.UserResp'
+ "400":
+ description: Bad Request
+ schema:
+ $ref: '#/definitions/api.HTTPErrorResp'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/api.HTTPErrorResp'
+ summary: Unfreeze user leaderboard
+ tags:
+ - info
+ /api/admin/users/{action}/{id}:
+ post:
+ consumes:
+ - application/json
+ description: Ban/Unban/Hide/Unhide a user based on his user id. This operation
+ can only be done by admins
+ parameters:
+ - description: Action to perform Ban/Unban/Hide/Unhide
+ in: query
+ name: action
+ required: true
+ type: string
+ - description: Id of user
+ in: query
+ name: id
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/api.ChallengeStatusResp'
+ "400":
+ description: Bad Request
+ schema:
+ $ref: '#/definitions/api.HTTPPlainResp'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/api.HTTPPlainResp'
+ summary: Ban/Unban/Hide/Unhide a user based on his id and the action provided.
+ tags:
+ - admin
+ /api/config/challenge-info:
+ post:
+ consumes:
+ - application/json
+ description: Updates challenge info in the database, located at ~/.beast/beast.db.
+ parameters:
+ - description: Challenge Name
+ in: formData
+ name: name
+ required: true
+ type: string
+ - description: Challenge's description
+ in: formData
+ name: desc
+ type: string
+ - description: Challenge's points
+ in: formData
+ name: points
+ type: string
+ - description: Challenge's flag
+ in: formData
+ name: flag
+ type: string
+ - description: Challenge's tags
+ in: formData
+ name: tags
+ type: string
+ - description: Challenge's ports
+ in: formData
+ name: ports
+ type: string
+ produces:
+ - application/json
responses:
"200":
description: OK
@@ -393,10 +842,14 @@ paths:
description: Bad Request
schema:
$ref: '#/definitions/api.HTTPPlainResp'
- summary: Reloads any changes in beast global configuration, located at ~/.beast/config.toml.
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/api.HTTPErrorResp'
+ summary: Updates challenge info in the database, located at ~/.beast/beast.db.
tags:
- config
- /api/info/challenge/info:
+ /api/info/challenge/{name}:
get:
consumes:
- application/json
@@ -408,7 +861,7 @@ paths:
required: true
type: string
- description: Name of challenge
- in: query
+ in: path
name: name
required: true
type: string
@@ -418,7 +871,7 @@ paths:
"200":
description: OK
schema:
- $ref: '#/definitions/api.ChallengeInfoResp'
+ $ref: '#/definitions/api.Challenge'
"400":
description: Bad Request
schema:
@@ -460,7 +913,9 @@ paths:
"200":
description: OK
schema:
- $ref: '#/definitions/api.ChallengeInfoResp'
+ items:
+ $ref: '#/definitions/api.ChallengeMetadata'
+ type: array
"400":
description: Bad Request
schema:
@@ -469,7 +924,115 @@ paths:
description: Internal Server Error
schema:
$ref: '#/definitions/api.HTTPErrorResp'
- summary: Returns information about all challenges with and without filters.
+ summary: Returns metadata about all challenges with and without filters.
+ tags:
+ - info
+ /api/info/competition-info:
+ get:
+ consumes:
+ - application/json
+ description: returns various information about the competition which are used
+ to control competition
+ parameters:
+ - description: Bearer
+ in: header
+ name: Authorization
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/api.CompetitionInfoResp'
+ "400":
+ description: Bad Request
+ schema:
+ $ref: '#/definitions/api.HTTPErrorResp'
+ summary: returns competition info
+ tags:
+ - info
+ /api/info/download:
+ get:
+ consumes:
+ - application/json
+ parameters:
+ - description: Bearer
+ in: header
+ name: Authorization
+ required: true
+ type: string
+ - description: The name of the challenge to get the logs for.
+ in: query
+ name: challenge
+ type: string
+ - description: The name of the static asset requested.
+ in: query
+ name: asset
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/api.LogsInfoResp'
+ "400":
+ description: Bad Request
+ schema:
+ $ref: '#/definitions/api.HTTPPlainResp'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/api.HTTPPlainResp'
+ tags:
+ - info
+ /api/info/hint/{hintID}:
+ get:
+ parameters:
+ - description: Hint ID
+ in: path
+ name: hintID
+ required: true
+ type: integer
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/api.HintResponse'
+ "400":
+ description: Bad Request
+ schema:
+ $ref: '#/definitions/api.HTTPErrorResp'
+ security:
+ - ApiKeyAuth: []
+ summary: Read or purchase a challenge hint
+ tags:
+ - info
+ post:
+ parameters:
+ - description: Hint ID
+ in: path
+ name: hintID
+ required: true
+ type: integer
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/api.HintResponse'
+ "400":
+ description: Bad Request
+ schema:
+ $ref: '#/definitions/api.HTTPErrorResp'
+ security:
+ - ApiKeyAuth: []
+ summary: Read or purchase a challenge hint
tags:
- info
/api/info/images/available:
@@ -495,21 +1058,20 @@ paths:
this is a constant specified in beast global config
tags:
- info
- /api/info/logs:
+ /api/info/leaderboard:
get:
consumes:
- application/json
- description: Gives container logs for a particular challenge, useful for debugging
- purposes.
+ description: Returns leaderboard of all users
parameters:
- description: Bearer
in: header
name: Authorization
required: true
type: string
- - description: The name of the challenge to get the logs for.
+ - description: Page number
in: query
- name: challenge
+ name: page
type: string
produces:
- application/json
@@ -517,25 +1079,45 @@ paths:
"200":
description: OK
schema:
- $ref: '#/definitions/api.LogsInfoResp'
+ $ref: '#/definitions/api.UserResp'
"400":
description: Bad Request
schema:
- $ref: '#/definitions/api.HTTPPlainResp'
+ $ref: '#/definitions/api.HTTPErrorResp'
"500":
description: Internal Server Error
schema:
- $ref: '#/definitions/api.HTTPPlainResp'
- summary: Handles route related to logs handling of container
+ $ref: '#/definitions/api.HTTPErrorResp'
+ summary: Returns leaderboard
tags:
- info
- /api/info/ports/used:
+ /api/info/leaderboard-graph:
+ get:
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ items:
+ $ref: '#/definitions/database.UserLeaderboardResp'
+ type: array
+ security:
+ - ApiKeyAuth: []
+ summary: Return leaderboard score history
+ tags:
+ - info
+ /api/info/submissions/challenge/{challenge_id}:
get:
consumes:
- application/json
- description: Returns the ports in use by beast, which cannot be used in creating
- a new challenge..
+ description: Returns all user attempts for a given challenge.
parameters:
+ - description: Challenge ID
+ in: path
+ name: challenge_id
+ required: true
+ type: integer
- description: Bearer
in: header
name: Authorization
@@ -547,18 +1129,31 @@ paths:
"200":
description: OK
schema:
- $ref: '#/definitions/api.PortsInUseResp'
- summary: Returns ports in use by beast by looking in the hack git repository,
- also returns min and max value of port allowed while specifying in beast challenge
- config.
+ items:
+ $ref: '#/definitions/api.UserSolveResp'
+ type: array
+ "400":
+ description: Bad Request
+ schema:
+ $ref: '#/definitions/api.HTTPErrorResp'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/api.HTTPErrorResp'
+ summary: Get challenge attempts
tags:
- info
- /api/info/submissions:
+ /api/info/submissions/user/{user_id}:
get:
consumes:
- application/json
- description: Handles submissions made by the user
+ description: Returns all submissions for a specific user
parameters:
+ - description: User ID
+ in: path
+ name: user_id
+ required: true
+ type: integer
- description: Bearer
in: header
name: Authorization
@@ -570,15 +1165,50 @@ paths:
"200":
description: OK
schema:
- $ref: '#/definitions/api.SubmissionResp'
+ items:
+ $ref: '#/definitions/api.SubmissionResp'
+ type: array
+ "400":
+ description: Bad Request
+ schema:
+ $ref: '#/definitions/api.HTTPErrorResp'
+ "404":
+ description: Not Found
+ schema:
+ $ref: '#/definitions/api.HTTPErrorResp'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/api.HTTPErrorResp'
- summary: Handles submissions made by the user
+ summary: Get submissions by user
tags:
- info
- /api/info/user:
+ /api/info/tags:
+ get:
+ consumes:
+ - application/json
+ description: returns all unique tags
+ parameters:
+ - description: Bearer
+ in: header
+ name: Authorization
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/api.TagInfoResp'
+ "400":
+ description: Bad Request
+ schema:
+ $ref: '#/definitions/api.HTTPErrorResp'
+ summary: returns all tags
+ tags:
+ - info
+ /api/info/user/{username}:
get:
consumes:
- application/json
@@ -589,13 +1219,233 @@ paths:
name: Authorization
required: true
type: string
- - description: User's id
+ - description: User ID
+ in: query
+ name: user_id
+ type: integer
+ - description: Username
+ in: path
+ name: username
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/api.UserResp'
+ "400":
+ description: Bad Request
+ schema:
+ $ref: '#/definitions/api.HTTPErrorResp'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/api.HTTPErrorResp'
+ summary: Returns user info
+ tags:
+ - info
+ /api/info/usercount:
+ get:
+ consumes:
+ - application/json
+ description: Returns the number of users in the database with role=contestant
+ parameters:
+ - description: Bearer
+ in: header
+ name: Authorization
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/api.UserCountResp'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/api.HTTPErrorResp'
+ summary: Returns the number of users in the database with role=contestant
+ tags:
+ - info
+ /api/info/users:
+ get:
+ consumes:
+ - application/json
+ description: Returns all available user's info
+ parameters:
+ - description: Bearer
+ in: header
+ name: Authorization
+ required: true
+ type: string
+ - description: Sort by username or score
+ in: query
+ name: sort
+ type: string
+ - description: 'Score order: asc or desc'
+ in: query
+ name: order
+ type: string
+ - description: Filter by banned, active, or hidden
+ in: query
+ name: filter
+ type: string
+ - description: 'Response format: json or csv'
+ in: query
+ name: format
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ items:
+ $ref: '#/definitions/api.UsersResp'
+ type: array
+ "404":
+ description: Not Found
+ schema:
+ $ref: '#/definitions/api.HTTPErrorResp'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/api.HTTPErrorResp'
+ summary: Returns all user's info
+ tags:
+ - info
+ /api/instances:
+ get:
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ items:
+ $ref: '#/definitions/api.InstanceResponse'
+ type: array
+ security:
+ - ApiKeyAuth: []
+ summary: List the current user's instances
+ tags:
+ - instances
+ /api/instances/{challenge_name}:
+ delete:
+ parameters:
+ - description: Challenge name
+ in: path
+ name: challenge_name
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/api.HTTPPlainResp'
+ security:
+ - ApiKeyAuth: []
+ summary: Delete the current user's challenge instance
+ tags:
+ - instances
+ get:
+ parameters:
+ - description: Challenge name
+ in: path
+ name: challenge_name
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/api.InstanceResponse'
+ "404":
+ description: Not Found
+ schema:
+ $ref: '#/definitions/api.HTTPErrorResp'
+ security:
+ - ApiKeyAuth: []
+ summary: Get the current user's challenge instance
+ tags:
+ - instances
+ /api/instances/{challenge_name}/extend:
+ post:
+ parameters:
+ - description: Challenge name
+ in: path
+ name: challenge_name
+ required: true
+ type: string
+ - description: Requested extension in seconds
in: formData
- name: value
+ name: seconds
+ type: integer
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/api.InstanceResponse'
+ "400":
+ description: Bad Request
+ schema:
+ $ref: '#/definitions/api.HTTPErrorResp'
+ security:
+ - ApiKeyAuth: []
+ summary: Extend the current user's challenge instance
+ tags:
+ - instances
+ /api/instances/{challenge_name}/spawn:
+ post:
+ parameters:
+ - description: Challenge name
+ in: path
+ name: challenge_name
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/api.InstanceResponse'
+ "400":
+ description: Bad Request
+ schema:
+ $ref: '#/definitions/api.HTTPErrorResp'
+ security:
+ - ApiKeyAuth: []
+ summary: Spawn a per-user challenge instance
+ tags:
+ - instances
+ /api/manage/challenge/:
+ post:
+ consumes:
+ - application/json
+ description: Handles challenge management routes with actions which includes
+ - DEPLOY, UNDEPLOY, PURGE.
+ parameters:
+ - description: Name of the challenge to be managed, here name is the unique
+ identifier for challenge
+ in: query
+ name: name
+ required: true
type: string
- - description: username
+ - description: Action for the challenge
in: query
- name: value
+ name: action
+ required: true
type: string
produces:
- application/json
@@ -603,56 +1453,59 @@ paths:
"200":
description: OK
schema:
- $ref: '#/definitions/api.UserResp'
+ $ref: '#/definitions/api.HTTPPlainResp'
"400":
description: Bad Request
schema:
- $ref: '#/definitions/api.HTTPErrorResp'
- "500":
- description: Internal Server Error
- schema:
- $ref: '#/definitions/api.HTTPErrorResp'
- summary: Returns user info
+ $ref: '#/definitions/api.HTTPPlainResp'
+ summary: Handles challenge management actions.
tags:
- - info
- /api/info/user/available:
- get:
+ - manage
+ /api/manage/challenge/{name}/exec:
+ post:
consumes:
- application/json
- description: Returns all available user's info
parameters:
- - description: Bearer
- in: header
- name: Authorization
+ - description: Challenge name
+ in: path
+ name: name
required: true
type: string
+ - description: Bounded exec request
+ in: body
+ name: request
+ required: true
+ schema:
+ $ref: '#/definitions/api.ExecChallengeRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
- $ref: '#/definitions/api.UserResp'
- "404":
- description: Not Found
+ $ref: '#/definitions/api.ExecChallengeResponse'
+ "400":
+ description: Bad Request
schema:
$ref: '#/definitions/api.HTTPErrorResp'
- "500":
- description: Internal Server Error
+ "403":
+ description: Forbidden
schema:
$ref: '#/definitions/api.HTTPErrorResp'
- summary: Returns all user's info
+ security:
+ - ApiKeyAuth: []
+ summary: Execute an argument-vector command in an owned challenge container
tags:
- - info
- /api/manage/challenge/:
+ - manage
+ /api/manage/challenge/multiple/:
post:
consumes:
- application/json
description: Handles challenge management routes with actions which includes
- - DEPLOY, UNDEPLOY, PURGE.
+ - DEPLOY, UNDEPLOY, PURGE of multiple challenges.
parameters:
- description: Name of the challenge to be managed, here name is the unique
- identifier for challenge
+ identifier for challenges seperated by a comma
in: query
name: name
required: true
@@ -673,17 +1526,15 @@ paths:
description: Bad Request
schema:
$ref: '#/definitions/api.HTTPPlainResp'
- summary: Handles challenge management actions.
- tags:
- - manage
+ summary: Handles multiple challenge management actions.
/api/manage/challenge/upload:
post:
consumes:
- application/json
- description: Handles the challenge management from a challenge in tar file.
- Currently prepare the tar file
+ description: Handles the challenge management from a challenge in zip file.
+ Currently prepare the zip file
parameters:
- - description: .tar file to be uploaded to fetch challenge info
+ - description: .zip file to be uploaded to fetch challenge info
in: formData
name: file
required: true
@@ -703,7 +1554,62 @@ paths:
description: Internal Server Error
schema:
$ref: '#/definitions/api.HTTPErrorResp'
- summary: Untar and fetch info from beast.toml file in challenge
+ summary: Unzip and fetch info from beast.toml file in challenge
+ tags:
+ - manage
+ /api/manage/challenge/validateflag:
+ post:
+ parameters:
+ - description: Challenge name
+ in: formData
+ name: challenge_name
+ required: true
+ type: string
+ - description: Flag to validate
+ in: formData
+ name: flag
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/api.HTTPPlainResp'
+ "403":
+ description: Forbidden
+ schema:
+ $ref: '#/definitions/api.HTTPErrorResp'
+ security:
+ - ApiKeyAuth: []
+ summary: Validate a configured challenge flag as its manager
+ tags:
+ - manage
+ /api/manage/challenge/verify:
+ post:
+ consumes:
+ - application/json
+ description: Commits the challenge container for later use
+ parameters:
+ - description: Name of the challenge to commit
+ in: query
+ name: challenge
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/api.HTTPPlainResp'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/api.HTTPPlainResp'
+ summary: Commits the challenge container so that later the challenge image can
+ be used deployment
tags:
- manage
/api/manage/commit/:
@@ -729,7 +1635,7 @@ paths:
can be deployed or not.
tags:
- manage
- /api/manage/deploy/local:
+ /api/manage/deploy/local/:
post:
consumes:
- application/json
@@ -759,7 +1665,43 @@ paths:
summary: Deploy a local challenge using the path provided in the post parameter
tags:
- manage
- /api/manage/multiple/:action:
+ /api/manage/logs:
+ get:
+ consumes:
+ - application/json
+ description: Gives container logs for a particular challenge, useful for debugging
+ purposes.
+ parameters:
+ - description: Bearer
+ in: header
+ name: Authorization
+ required: true
+ type: string
+ - description: The name of the challenge to get the logs for.
+ in: query
+ name: challenge
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/api.LogsInfoResp'
+ "400":
+ description: Bad Request
+ schema:
+ $ref: '#/definitions/api.HTTPPlainResp'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/api.HTTPPlainResp'
+ security:
+ - ApiKeyAuth: []
+ summary: Handles route related to logs handling of container
+ tags:
+ - info
+ /api/manage/multiple/{action}:
post:
consumes:
- application/json
@@ -789,7 +1731,7 @@ paths:
summary: Handles challenge management actions for multiple challenges.
tags:
- manage
- /api/manage/schedule/:action:
+ /api/manage/schedule/{action}:
post:
consumes:
- application/json
@@ -839,7 +1781,7 @@ paths:
summary: Schedule an action(deploy, undeploy, purge etc.) on a particular challenge
tags:
- manage
- /api/manage/static/:action:
+ /api/manage/static/{action}:
post:
consumes:
- application/json
@@ -900,7 +1842,7 @@ paths:
tags:
- notification
/api/notification/available:
- post:
+ get:
consumes:
- application/json
description: Fetch all the notifications from database
@@ -919,7 +1861,7 @@ paths:
tags:
- notification
/api/notification/delete:
- post:
+ delete:
consumes:
- application/json
description: Removes notifications
@@ -947,8 +1889,22 @@ paths:
summary: Removes notifications
tags:
- notification
+ /api/notification/stream:
+ get:
+ produces:
+ - text/event-stream
+ responses:
+ "200":
+ description: SSE stream
+ schema:
+ type: string
+ security:
+ - ApiKeyAuth: []
+ summary: Stream server-sent notifications
+ tags:
+ - notification
/api/notification/update:
- post:
+ put:
consumes:
- application/json
description: Updates any changes in the notifications
@@ -986,7 +1942,7 @@ paths:
summary: Updates notifications
tags:
- notification
- /api/remote/reset/:
+ /api/remote/reset:
post:
consumes:
- application/json
@@ -1012,7 +1968,7 @@ paths:
summary: Resets beast local copy of remote git repository.
tags:
- remote
- /api/remote/sync/:
+ /api/remote/sync:
post:
consumes:
- application/json
@@ -1038,7 +1994,45 @@ paths:
summary: Syncs beast's local copy of remote git repository for challenges.
tags:
- remote
- /api/status/all/:filter:
+ /api/status/all:
+ get:
+ consumes:
+ - application/json
+ description: This returns the challenges in the status provided, along with
+ their name and last updated time.
+ parameters:
+ - description: Status type to filter with, if none specified then all
+ in: query
+ name: filter
+ required: true
+ type: string
+ - description: Bearer
+ in: header
+ name: Authorization
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ items:
+ $ref: '#/definitions/api.ChallengeStatusResp'
+ type: array
+ "400":
+ description: Bad Request
+ schema:
+ $ref: '#/definitions/api.HTTPPlainResp'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/api.HTTPPlainResp'
+ summary: Returns challenge deployment status from the beast database for the
+ challenges which matches the stauts according to filter.
+ tags:
+ - status
+ /api/status/all/{filter}:
get:
consumes:
- application/json
@@ -1076,7 +2070,7 @@ paths:
challenges which matches the stauts according to filter.
tags:
- status
- /api/status/challenge/:name:
+ /api/status/challenge/{name}:
get:
consumes:
- application/json
@@ -1212,10 +2206,6 @@ paths:
name: email
required: true
type: string
- - description: User's ssh-key
- in: formData
- name: ssh-key
- type: string
produces:
- application/json
responses:
@@ -1261,6 +2251,114 @@ paths:
summary: Resets password for the user
tags:
- auth
+ /auth/send-otp:
+ post:
+ consumes:
+ - multipart/form-data
+ parameters:
+ - description: Email address
+ in: formData
+ name: email
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/api.HTTPPlainResp'
+ "400":
+ description: Bad Request
+ schema:
+ $ref: '#/definitions/api.HTTPErrorResp'
+ summary: Send an email verification OTP
+ tags:
+ - auth
+ /auth/send-otp-forget:
+ post:
+ consumes:
+ - multipart/form-data
+ parameters:
+ - description: Registered email address
+ in: formData
+ name: email
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/api.HTTPPlainResp'
+ "400":
+ description: Bad Request
+ schema:
+ $ref: '#/definitions/api.HTTPErrorResp'
+ summary: Send a password-reset OTP
+ tags:
+ - auth
+ /auth/verify-otp:
+ post:
+ consumes:
+ - multipart/form-data
+ parameters:
+ - description: Email address
+ in: formData
+ name: email
+ required: true
+ type: string
+ - description: One-time code
+ in: formData
+ name: otp
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/api.HTTPPlainResp'
+ "400":
+ description: Bad Request
+ schema:
+ $ref: '#/definitions/api.HTTPErrorResp'
+ summary: Verify an email OTP
+ tags:
+ - auth
+ /auth/verify-otp-forget:
+ post:
+ consumes:
+ - multipart/form-data
+ parameters:
+ - description: Registered email address
+ in: formData
+ name: email
+ required: true
+ type: string
+ - description: One-time code
+ in: formData
+ name: otp
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/api.HTTPAuthorizeResp'
+ "400":
+ description: Bad Request
+ schema:
+ $ref: '#/definitions/api.HTTPErrorResp'
+ summary: Verify a password-reset OTP
+ tags:
+ - auth
+schemes:
+- https
securityDefinitions:
ApiKeyAuth:
in: header
diff --git a/api/docs/swagger/swagger.json b/api/docs/swagger/swagger.json
deleted file mode 100644
index 2294d64b..00000000
--- a/api/docs/swagger/swagger.json
+++ /dev/null
@@ -1,1850 +0,0 @@
-{
- "swagger": "2.0",
- "info": {
- "description": "Beast the automatic deployment tool for backdoor",
- "title": "Beast API",
- "contact": {
- "name": "SDSLabs",
- "url": "https://chat.sdslabs.co",
- "email": "contact.sdslabs.co.in"
- },
- "license": {
- "name": "Apache 2.0",
- "url": "http://www.apache.org/licenses/LICENSE-2.0.html"
- },
- "version": "1.0"
- },
- "host": "beast.sdslabs.co",
- "basePath": "/",
- "paths": {
- "/api/admin/statistics": {
- "get": {
- "description": "returns various information about the competition which are used to control competition",
- "consumes": [
- "application/json"
- ],
- "produces": [
- "application/json"
- ],
- "tags": [
- "info"
- ],
- "summary": "returns competition info",
- "parameters": [
- {
- "type": "string",
- "description": "Bearer",
- "name": "Authorization",
- "in": "header",
- "required": true
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/api.CompetitionInfoResp"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/api.HTTPErrorResp"
- }
- }
- }
- }
- },
- "/api/admin/users/:action/:id": {
- "post": {
- "description": "Ban/unban a user based on his user id. This operation can only be done by admins",
- "consumes": [
- "application/json"
- ],
- "produces": [
- "application/json"
- ],
- "tags": [
- "admin"
- ],
- "summary": "Ban/Unban a user based on his id and the action provided.",
- "parameters": [
- {
- "type": "string",
- "description": "Action to perform ban/unban",
- "name": "action",
- "in": "query",
- "required": true
- },
- {
- "type": "string",
- "description": "Id of user",
- "name": "id",
- "in": "query",
- "required": true
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/api.ChallengeStatusResp"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/api.HTTPPlainResp"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/api.HTTPPlainResp"
- }
- }
- }
- }
- },
- "/api/config/competition-info": {
- "post": {
- "description": "Populates beast gobal config map by reparsing the config file $HOME/.beast/config.toml.",
- "consumes": [
- "application/json"
- ],
- "produces": [
- "application/json"
- ],
- "tags": [
- "config"
- ],
- "summary": "Updates competition info in the beast global configuration, located at ~/.beast/config.toml.",
- "parameters": [
- {
- "type": "string",
- "description": "Competition Name",
- "name": "name",
- "in": "formData",
- "required": true
- },
- {
- "type": "string",
- "description": "Some information about competition",
- "name": "about",
- "in": "formData",
- "required": true
- },
- {
- "type": "string",
- "description": "Competitions Prizes for the winners",
- "name": "prizes",
- "in": "formData"
- },
- {
- "type": "string",
- "description": "Competition's starting time",
- "name": "starting_time",
- "in": "formData",
- "required": true
- },
- {
- "type": "string",
- "description": "Competition's ending time",
- "name": "ending_time",
- "in": "formData",
- "required": true
- },
- {
- "type": "string",
- "description": "Competition's timezone",
- "name": "timezone",
- "in": "formData",
- "required": true
- },
- {
- "type": "file",
- "description": "Competition's logo",
- "name": "logo",
- "in": "formData"
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/api.HTTPPlainResp"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/api.HTTPPlainResp"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/api.HTTPErrorResp"
- }
- }
- }
- }
- },
- "/api/config/reload/": {
- "patch": {
- "description": "Populates beast gobal config map by reparsing the config file $HOME/.beast/config.toml.",
- "consumes": [
- "application/json"
- ],
- "produces": [
- "application/json"
- ],
- "tags": [
- "config"
- ],
- "summary": "Reloads any changes in beast global configuration, located at ~/.beast/config.toml.",
- "parameters": [
- {
- "type": "string",
- "description": "Bearer",
- "name": "Authorization",
- "in": "header",
- "required": true
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/api.HTTPPlainResp"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/api.HTTPPlainResp"
- }
- }
- }
- }
- },
- "/api/info/challenge/info": {
- "get": {
- "description": "Returns all information about the challenges by the challenge name.",
- "consumes": [
- "application/json"
- ],
- "produces": [
- "application/json"
- ],
- "tags": [
- "info"
- ],
- "summary": "Returns all information about the challenges.",
- "parameters": [
- {
- "type": "string",
- "description": "Bearer",
- "name": "Authorization",
- "in": "header",
- "required": true
- },
- {
- "type": "string",
- "description": "Name of challenge",
- "name": "name",
- "in": "query",
- "required": true
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/api.ChallengeInfoResp"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/api.HTTPErrorResp"
- }
- },
- "404": {
- "description": "Not Found",
- "schema": {
- "$ref": "#/definitions/api.HTTPErrorResp"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/api.HTTPErrorResp"
- }
- }
- }
- }
- },
- "/api/info/challenges": {
- "get": {
- "description": "Returns information about all the challenges present in the database with and without filters.",
- "consumes": [
- "application/json"
- ],
- "produces": [
- "application/json"
- ],
- "tags": [
- "info"
- ],
- "summary": "Returns information about all challenges with and without filters.",
- "parameters": [
- {
- "type": "string",
- "description": "Filter parameter by which challenges are filtered",
- "name": "filter",
- "in": "query"
- },
- {
- "type": "string",
- "description": "Value of filtered parameter",
- "name": "value",
- "in": "query"
- },
- {
- "type": "string",
- "description": "Bearer",
- "name": "Authorization",
- "in": "header",
- "required": true
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/api.ChallengeInfoResp"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/api.HTTPErrorResp"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/api.HTTPErrorResp"
- }
- }
- }
- }
- },
- "/api/info/images/available": {
- "get": {
- "description": "Returns all the available base images which can be used for challenge creation as the base OS for challenge.",
- "consumes": [
- "application/json"
- ],
- "produces": [
- "application/json"
- ],
- "tags": [
- "info"
- ],
- "summary": "Gives all the base images that can be used while creating a beast challenge, this is a constant specified in beast global config",
- "parameters": [
- {
- "type": "string",
- "description": "Bearer",
- "name": "Authorization",
- "in": "header",
- "required": true
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/api.AvailableImagesResp"
- }
- }
- }
- }
- },
- "/api/info/logs": {
- "get": {
- "description": "Gives container logs for a particular challenge, useful for debugging purposes.",
- "consumes": [
- "application/json"
- ],
- "produces": [
- "application/json"
- ],
- "tags": [
- "info"
- ],
- "summary": "Handles route related to logs handling of container",
- "parameters": [
- {
- "type": "string",
- "description": "Bearer",
- "name": "Authorization",
- "in": "header",
- "required": true
- },
- {
- "type": "string",
- "description": "The name of the challenge to get the logs for.",
- "name": "challenge",
- "in": "query"
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/api.LogsInfoResp"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/api.HTTPPlainResp"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/api.HTTPPlainResp"
- }
- }
- }
- }
- },
- "/api/info/ports/used": {
- "get": {
- "description": "Returns the ports in use by beast, which cannot be used in creating a new challenge..",
- "consumes": [
- "application/json"
- ],
- "produces": [
- "application/json"
- ],
- "tags": [
- "info"
- ],
- "summary": "Returns ports in use by beast by looking in the hack git repository, also returns min and max value of port allowed while specifying in beast challenge config.",
- "parameters": [
- {
- "type": "string",
- "description": "Bearer",
- "name": "Authorization",
- "in": "header",
- "required": true
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/api.PortsInUseResp"
- }
- }
- }
- }
- },
- "/api/info/submissions": {
- "get": {
- "description": "Handles submissions made by the user",
- "consumes": [
- "application/json"
- ],
- "produces": [
- "application/json"
- ],
- "tags": [
- "info"
- ],
- "summary": "Handles submissions made by the user",
- "parameters": [
- {
- "type": "string",
- "description": "Bearer",
- "name": "Authorization",
- "in": "header",
- "required": true
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/api.SubmissionResp"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/api.HTTPErrorResp"
- }
- }
- }
- }
- },
- "/api/info/user": {
- "get": {
- "description": "Returns user info based on userId",
- "consumes": [
- "application/json"
- ],
- "produces": [
- "application/json"
- ],
- "tags": [
- "info"
- ],
- "summary": "Returns user info",
- "parameters": [
- {
- "type": "string",
- "description": "Bearer",
- "name": "Authorization",
- "in": "header",
- "required": true
- },
- {
- "type": "string",
- "description": "User's id",
- "name": "value",
- "in": "formData"
- },
- {
- "type": "string",
- "description": "username",
- "name": "value",
- "in": "query"
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/api.UserResp"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/api.HTTPErrorResp"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/api.HTTPErrorResp"
- }
- }
- }
- }
- },
- "/api/info/user/available": {
- "get": {
- "description": "Returns all available user's info",
- "consumes": [
- "application/json"
- ],
- "produces": [
- "application/json"
- ],
- "tags": [
- "info"
- ],
- "summary": "Returns all user's info",
- "parameters": [
- {
- "type": "string",
- "description": "Bearer",
- "name": "Authorization",
- "in": "header",
- "required": true
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/api.UserResp"
- }
- },
- "404": {
- "description": "Not Found",
- "schema": {
- "$ref": "#/definitions/api.HTTPErrorResp"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/api.HTTPErrorResp"
- }
- }
- }
- }
- },
- "/api/manage/challenge/": {
- "post": {
- "description": "Handles challenge management routes with actions which includes - DEPLOY, UNDEPLOY, PURGE.",
- "consumes": [
- "application/json"
- ],
- "produces": [
- "application/json"
- ],
- "tags": [
- "manage"
- ],
- "summary": "Handles challenge management actions.",
- "parameters": [
- {
- "type": "string",
- "description": "Name of the challenge to be managed, here name is the unique identifier for challenge",
- "name": "name",
- "in": "query",
- "required": true
- },
- {
- "type": "string",
- "description": "Action for the challenge",
- "name": "action",
- "in": "query",
- "required": true
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/api.HTTPPlainResp"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/api.HTTPPlainResp"
- }
- }
- }
- }
- },
- "/api/manage/challenge/upload": {
- "post": {
- "description": "Handles the challenge management from a challenge in tar file. Currently prepare the tar file",
- "consumes": [
- "application/json"
- ],
- "produces": [
- "application/json"
- ],
- "tags": [
- "manage"
- ],
- "summary": "Untar and fetch info from beast.toml file in challenge",
- "parameters": [
- {
- "type": "file",
- "description": ".tar file to be uploaded to fetch challenge info",
- "name": "file",
- "in": "formData",
- "required": true
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/api.ChallengePreviewResp"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/api.HTTPErrorResp"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/api.HTTPErrorResp"
- }
- }
- }
- }
- },
- "/api/manage/commit/": {
- "post": {
- "description": "Validates challenge configuration for deployment.",
- "consumes": [
- "application/json"
- ],
- "produces": [
- "application/json"
- ],
- "tags": [
- "manage"
- ],
- "summary": "Validates the configuration of the challenge and tells if challenge can be deployed or not.",
- "parameters": [
- {
- "type": "string",
- "description": "Name of the challenge to verify the deployment configuration for.",
- "name": "challenge",
- "in": "query",
- "required": true
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/api.HTTPErrorResp"
- }
- }
- }
- }
- },
- "/api/manage/deploy/local": {
- "post": {
- "description": "Handles deployment of a challenge using the absolute directory path",
- "consumes": [
- "application/json"
- ],
- "produces": [
- "application/json"
- ],
- "tags": [
- "manage"
- ],
- "summary": "Deploy a local challenge using the path provided in the post parameter",
- "parameters": [
- {
- "type": "string",
- "description": "Challenge Directory",
- "name": "challenge_dir",
- "in": "query",
- "required": true
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/api.HTTPPlainResp"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/api.HTTPPlainResp"
- }
- },
- "406": {
- "description": "Not Acceptable",
- "schema": {
- "$ref": "#/definitions/api.HTTPPlainResp"
- }
- }
- }
- }
- },
- "/api/manage/multiple/:action": {
- "post": {
- "description": "Handles challenge management routes for multiple the challenges with actions which includes - DEPLOY, UNDEPLOY.",
- "consumes": [
- "application/json"
- ],
- "produces": [
- "application/json"
- ],
- "tags": [
- "manage"
- ],
- "summary": "Handles challenge management actions for multiple challenges.",
- "parameters": [
- {
- "type": "string",
- "description": "Action for the challenge",
- "name": "action",
- "in": "query",
- "required": true
- },
- {
- "type": "string",
- "description": "Tag for a group of challenges",
- "name": "tag",
- "in": "query"
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/api.HTTPPlainResp"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/api.HTTPPlainResp"
- }
- }
- }
- }
- },
- "/api/manage/schedule/:action": {
- "post": {
- "description": "Handles scheduleing of challenge action to executed at some later point of time",
- "consumes": [
- "application/json"
- ],
- "produces": [
- "application/json"
- ],
- "tags": [
- "manage"
- ],
- "summary": "Schedule an action(deploy, undeploy, purge etc.) on a particular challenge",
- "parameters": [
- {
- "type": "string",
- "description": "Bearer",
- "name": "Authorization",
- "in": "header",
- "required": true
- },
- {
- "type": "string",
- "description": "Action for the underlying challenge in context",
- "name": "action",
- "in": "query",
- "required": true
- },
- {
- "type": "string",
- "description": "The name of the challenge to schedule the action for.",
- "name": "challenge",
- "in": "query"
- },
- {
- "type": "string",
- "description": "Tag corresponding to challenges in context, optional if challenge name is provided",
- "name": "tags",
- "in": "query"
- },
- {
- "type": "string",
- "description": "Timestamp at which the challenge should be scheduled should be a unix timestamp string.",
- "name": "at",
- "in": "query"
- },
- {
- "type": "string",
- "description": "Time after which the action on the selector should be executed should be of duration format as in '1m20s' etc.",
- "name": "after",
- "in": "query"
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/api.HTTPPlainResp"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/api.HTTPPlainResp"
- }
- }
- }
- }
- },
- "/api/manage/static/:action": {
- "post": {
- "description": "Handles beast static content serving container routes.",
- "consumes": [
- "application/json"
- ],
- "produces": [
- "application/json"
- ],
- "tags": [
- "manage"
- ],
- "summary": "Handles route related to beast static content serving container, takes action as route parameter and perform that action",
- "parameters": [
- {
- "type": "string",
- "description": "Action to apply on the beast static content provider",
- "name": "action",
- "in": "query",
- "required": true
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/api.HTTPPlainResp"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/api.HTTPPlainResp"
- }
- }
- }
- }
- },
- "/api/notification/add": {
- "post": {
- "description": "Adds notifications",
- "consumes": [
- "application/json"
- ],
- "produces": [
- "application/json"
- ],
- "tags": [
- "notification"
- ],
- "summary": "Adds notifications",
- "parameters": [
- {
- "type": "string",
- "description": "Title of notification to be added",
- "name": "title",
- "in": "formData",
- "required": true
- },
- {
- "type": "string",
- "description": "Description for the notification to be added",
- "name": "desc",
- "in": "formData",
- "required": true
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/api.HTTPPlainResp"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/api.HTTPErrorResp"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/api.HTTPErrorResp"
- }
- }
- }
- }
- },
- "/api/notification/available": {
- "post": {
- "description": "Fetch all the notifications from database",
- "consumes": [
- "application/json"
- ],
- "produces": [
- "application/json"
- ],
- "tags": [
- "notification"
- ],
- "summary": "Fetch available notifications",
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/api.HTTPPlainResp"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/api.HTTPErrorResp"
- }
- }
- }
- }
- },
- "/api/notification/delete": {
- "post": {
- "description": "Removes notifications",
- "consumes": [
- "application/json"
- ],
- "produces": [
- "application/json"
- ],
- "tags": [
- "notification"
- ],
- "summary": "Removes notifications",
- "parameters": [
- {
- "type": "string",
- "description": "Title of notification",
- "name": "id",
- "in": "formData",
- "required": true
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/api.HTTPPlainResp"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/api.HTTPErrorResp"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/api.HTTPErrorResp"
- }
- }
- }
- }
- },
- "/api/notification/update": {
- "post": {
- "description": "Updates any changes in the notifications",
- "consumes": [
- "application/json"
- ],
- "produces": [
- "application/json"
- ],
- "tags": [
- "notification"
- ],
- "summary": "Updates notifications",
- "parameters": [
- {
- "type": "string",
- "description": "Title of notification",
- "name": "id",
- "in": "formData",
- "required": true
- },
- {
- "type": "string",
- "description": "Title of notification",
- "name": "title",
- "in": "formData",
- "required": true
- },
- {
- "type": "string",
- "description": "Description for the notification to be changed",
- "name": "desc",
- "in": "formData",
- "required": true
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/api.HTTPPlainResp"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/api.HTTPErrorResp"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/api.HTTPErrorResp"
- }
- }
- }
- }
- },
- "/api/remote/reset/": {
- "post": {
- "description": "Resets local copy of remote git directory, it first deletes the existing directory and then clone from the remote again.",
- "consumes": [
- "application/json"
- ],
- "produces": [
- "application/json"
- ],
- "tags": [
- "remote"
- ],
- "summary": "Resets beast local copy of remote git repository.",
- "parameters": [
- {
- "type": "string",
- "description": "Bearer",
- "name": "Authorization",
- "in": "header",
- "required": true
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/api.HTTPPlainResp"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/api.HTTPPlainResp"
- }
- }
- }
- }
- },
- "/api/remote/sync/": {
- "post": {
- "description": "Syncs beasts local challenges database with the remote git repository(hack) the local copy of the challenge database is located at $HOME/.beast/remote/$REMOTE_NAME.",
- "consumes": [
- "application/json"
- ],
- "produces": [
- "application/json"
- ],
- "tags": [
- "remote"
- ],
- "summary": "Syncs beast's local copy of remote git repository for challenges.",
- "parameters": [
- {
- "type": "string",
- "description": "Bearer",
- "name": "Authorization",
- "in": "header",
- "required": true
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/api.HTTPPlainResp"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/api.HTTPPlainResp"
- }
- }
- }
- }
- },
- "/api/status/all/:filter": {
- "get": {
- "description": "This returns the challenges in the status provided, along with their name and last updated time.",
- "consumes": [
- "application/json"
- ],
- "produces": [
- "application/json"
- ],
- "tags": [
- "status"
- ],
- "summary": "Returns challenge deployment status from the beast database for the challenges which matches the stauts according to filter.",
- "parameters": [
- {
- "type": "string",
- "description": "Status type to filter with, if none specified then all",
- "name": "filter",
- "in": "query",
- "required": true
- },
- {
- "type": "string",
- "description": "Bearer",
- "name": "Authorization",
- "in": "header",
- "required": true
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "type": "array",
- "items": {
- "$ref": "#/definitions/api.ChallengeStatusResp"
- }
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/api.HTTPPlainResp"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/api.HTTPPlainResp"
- }
- }
- }
- }
- },
- "/api/status/challenge/:name": {
- "get": {
- "description": "Returns challenge deployment status from the beast database, for those challenges which are not present a status value NA is returned.",
- "consumes": [
- "application/json"
- ],
- "produces": [
- "application/json"
- ],
- "tags": [
- "status"
- ],
- "summary": "Returns challenge deployment status from the beast database.",
- "parameters": [
- {
- "type": "string",
- "description": "Name of the challenge",
- "name": "name",
- "in": "query",
- "required": true
- },
- {
- "type": "string",
- "description": "Bearer",
- "name": "Authorization",
- "in": "header",
- "required": true
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/api.ChallengeStatusResp"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/api.HTTPPlainResp"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/api.HTTPPlainResp"
- }
- }
- }
- }
- },
- "/api/submit/challenge": {
- "post": {
- "description": "Returns success or error response based on the flag submitted. Also, the flag will not be submitted if it was previously submitted",
- "consumes": [
- "application/json"
- ],
- "produces": [
- "application/json"
- ],
- "tags": [
- "Submit"
- ],
- "summary": "Verifies and creates an entry in the database for successful submission of flag for a challenge.",
- "parameters": [
- {
- "type": "string",
- "description": "Name of challenge",
- "name": "chall_id",
- "in": "formData",
- "required": true
- },
- {
- "type": "string",
- "description": "Flag for the challenge",
- "name": "flag",
- "in": "formData",
- "required": true
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/api.ChallengeStatusResp"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/api.HTTPPlainResp"
- }
- },
- "401": {
- "description": "Unauthorized",
- "schema": {
- "$ref": "#/definitions/api.HTTPPlainResp"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/api.HTTPPlainResp"
- }
- }
- }
- }
- },
- "/auth/login": {
- "post": {
- "description": "JWT can be received by signing in",
- "consumes": [
- "application/json"
- ],
- "produces": [
- "application/json"
- ],
- "tags": [
- "auth"
- ],
- "summary": "Handles signin and token production",
- "parameters": [
- {
- "type": "string",
- "description": "Username",
- "name": "username",
- "in": "formData",
- "required": true
- },
- {
- "type": "string",
- "description": "Password",
- "name": "password",
- "in": "formData",
- "required": true
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/api.HTTPAuthorizeResp"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/api.HTTPPlainResp"
- }
- },
- "401": {
- "description": "Unauthorized",
- "schema": {
- "$ref": "#/definitions/api.HTTPPlainResp"
- }
- },
- "403": {
- "description": "Forbidden",
- "schema": {
- "$ref": "#/definitions/api.HTTPPlainResp"
- }
- }
- }
- }
- },
- "/auth/register": {
- "post": {
- "description": "Signup route for the user",
- "produces": [
- "application/json"
- ],
- "tags": [
- "auth"
- ],
- "summary": "Signup for the user",
- "parameters": [
- {
- "type": "string",
- "description": "User's name",
- "name": "name",
- "in": "formData"
- },
- {
- "type": "string",
- "description": "Username",
- "name": "username",
- "in": "formData",
- "required": true
- },
- {
- "type": "string",
- "description": "Password",
- "name": "password",
- "in": "formData",
- "required": true
- },
- {
- "type": "string",
- "description": "User's email id",
- "name": "email",
- "in": "formData",
- "required": true
- },
- {
- "type": "string",
- "description": "User's ssh-key",
- "name": "ssh-key",
- "in": "formData"
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/api.HTTPPlainResp"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/api.HTTPPlainResp"
- }
- },
- "406": {
- "description": "Not Acceptable",
- "schema": {
- "$ref": "#/definitions/api.HTTPPlainResp"
- }
- }
- }
- }
- },
- "/auth/reset-password": {
- "post": {
- "description": "Resets password for the user",
- "produces": [
- "application/json"
- ],
- "tags": [
- "auth"
- ],
- "summary": "Resets password for the user",
- "parameters": [
- {
- "type": "string",
- "description": "New Password",
- "name": "new_pass",
- "in": "formData",
- "required": true
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/api.HTTPPlainResp"
- }
- },
- "401": {
- "description": "Unauthorized",
- "schema": {
- "$ref": "#/definitions/api.HTTPPlainResp"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/api.HTTPPlainResp"
- }
- }
- }
- }
- }
- },
- "definitions": {
- "api.AvailableImagesResp": {
- "type": "object",
- "properties": {
- "images": {
- "type": "array",
- "items": {
- "type": "string"
- },
- "example": [
- "['ubuntu16.04'",
- " 'ubuntu18.04']"
- ]
- },
- "message": {
- "type": "string",
- "example": "Available Base images."
- }
- }
- },
- "api.ChallengeInfoResp": {
- "type": "object",
- "properties": {
- "category": {
- "type": "string",
- "example": "web"
- },
- "createdAt": {
- "type": "string"
- },
- "description": {
- "type": "string"
- },
- "hints": {
- "type": "string"
- },
- "id": {
- "type": "integer"
- },
- "name": {
- "type": "string",
- "example": "Web Challenge"
- },
- "points": {
- "type": "integer",
- "example": 50
- },
- "ports": {
- "type": "array",
- "items": {
- "type": "integer"
- }
- },
- "solves": {
- "type": "array",
- "items": {
- "$ref": "#/definitions/api.UserSolveResp"
- }
- },
- "solvesNumber": {
- "type": "integer",
- "example": 100
- },
- "status": {
- "type": "string",
- "example": "deployed"
- }
- }
- },
- "api.ChallengePreviewResp": {
- "type": "object",
- "properties": {
- "category": {
- "type": "string",
- "example": "web"
- },
- "description": {
- "type": "string"
- },
- "hints": {
- "type": "array",
- "items": {
- "type": "string"
- }
- },
- "name": {
- "type": "string",
- "example": "Web Challenge"
- },
- "points": {
- "type": "integer",
- "example": 50
- },
- "ports": {
- "type": "array",
- "items": {
- "type": "integer"
- }
- }
- }
- },
- "api.ChallengeSolveResp": {
- "type": "object",
- "properties": {
- "category": {
- "type": "string",
- "example": "web"
- },
- "id": {
- "type": "integer",
- "example": 4
- },
- "name": {
- "type": "string",
- "example": "Web Challenge"
- },
- "points": {
- "type": "integer",
- "example": 50
- },
- "solvedAt": {
- "type": "string"
- }
- }
- },
- "api.ChallengeStatusResp": {
- "type": "object",
- "properties": {
- "name": {
- "type": "string",
- "example": "Web Challenge"
- },
- "status": {
- "type": "string",
- "example": "deployed"
- },
- "updated_at": {
- "type": "string",
- "example": "2018-12-31T22:20:08.948096189+05:30"
- }
- }
- },
- "api.CompetitionInfoResp": {
- "type": "object",
- "properties": {
- "about": {
- "type": "string",
- "example": "This is a CTF competition"
- },
- "ending_time": {
- "type": "string"
- },
- "logo_url": {
- "type": "string"
- },
- "name": {
- "type": "string",
- "example": "fristonio"
- },
- "prizes": {
- "type": "string",
- "example": "1st and 2nd place winners will get $10K"
- },
- "starting_time": {
- "type": "string"
- },
- "timezone": {
- "type": "string"
- }
- }
- },
- "api.HTTPAuthorizeResp": {
- "type": "object",
- "properties": {
- "message": {
- "type": "string",
- "example": "Response message"
- },
- "role": {
- "type": "string",
- "example": "author"
- },
- "token": {
- "type": "string",
- "example": "YOUR_AUTHENTICATION_TOKEN"
- }
- }
- },
- "api.HTTPErrorResp": {
- "type": "object",
- "properties": {
- "error": {
- "type": "string",
- "example": "Error occured while veifying the challenge."
- }
- }
- },
- "api.HTTPPlainResp": {
- "type": "object",
- "properties": {
- "message": {
- "type": "string",
- "example": "Messsage in response to your request"
- }
- }
- },
- "api.LogsInfoResp": {
- "type": "object",
- "properties": {
- "stderr": {
- "type": "string",
- "example": "[ERROR] Challenge deployment failed."
- },
- "stdout": {
- "type": "string",
- "example": "[INFO] Challenge is starting to deploy"
- }
- }
- },
- "api.PortsInUseResp": {
- "type": "object",
- "properties": {
- "port_max_value": {
- "type": "integer",
- "example": 20000
- },
- "port_min_value": {
- "type": "integer",
- "example": 10000
- },
- "ports_in_use": {
- "type": "array",
- "items": {
- "type": "integer"
- }
- }
- }
- },
- "api.SubmissionResp": {
- "type": "object",
- "properties": {
- "category": {
- "type": "string",
- "example": "web"
- },
- "chall_id": {
- "type": "integer",
- "example": 3
- },
- "name": {
- "type": "string",
- "example": "Web Challenge"
- },
- "points": {
- "type": "integer",
- "example": 50
- },
- "solvedAt": {
- "type": "string"
- },
- "user_id": {
- "type": "integer",
- "example": 3
- },
- "username": {
- "type": "string",
- "example": "fristonio"
- },
- "flag": {
- "type": "string",
- "example": "flag{@#$}"
- }
- }
- },
- "api.UserResp": {
- "type": "object",
- "properties": {
- "challenges": {
- "type": "array",
- "items": {
- "$ref": "#/definitions/api.ChallengeSolveResp"
- }
- },
- "email": {
- "type": "string",
- "example": "fristonio@gmail.com"
- },
- "id": {
- "type": "integer",
- "example": 5
- },
- "rank": {
- "type": "integer",
- "example": 15
- },
- "role": {
- "type": "string",
- "example": "author"
- },
- "score": {
- "type": "integer",
- "example": 750
- },
- "status": {
- "type": "integer",
- "example": 0
- },
- "username": {
- "type": "string",
- "example": "CTF is live now!"
- }
- }
- },
- "api.UserSolveResp": {
- "type": "object",
- "properties": {
- "id": {
- "type": "integer",
- "example": 5
- },
- "solvedAt": {
- "type": "string"
- },
- "username": {
- "type": "string",
- "example": "fristonio"
- }
- }
- },
- "api.UsersStatisticsResp": {
- "type": "object",
- "properties": {
- "banned_users": {
- "type": "integer"
- },
- "total_registered_users": {
- "type": "integer",
- "example": 120
- },
- "unbanned_users": {
- "type": "integer"
- }
- }
- }
- },
- "securityDefinitions": {
- "ApiKeyAuth": {
- "type": "apiKey",
- "name": "Authorization",
- "in": "header"
- }
- }
-}
\ No newline at end of file
diff --git a/api/docs/swagger/swagger.yaml b/api/docs/swagger/swagger.yaml
deleted file mode 100644
index f81d0b2e..00000000
--- a/api/docs/swagger/swagger.yaml
+++ /dev/null
@@ -1,1269 +0,0 @@
-basePath: /
-definitions:
- api.AvailableImagesResp:
- properties:
- images:
- example:
- - '[''ubuntu16.04'''
- - ' ''ubuntu18.04'']'
- items:
- type: string
- type: array
- message:
- example: Available Base images.
- type: string
- type: object
- api.ChallengeInfoResp:
- properties:
- category:
- example: web
- type: string
- createdAt:
- type: string
- description:
- type: string
- hints:
- type: string
- id:
- type: integer
- name:
- example: Web Challenge
- type: string
- points:
- example: 50
- type: integer
- ports:
- items:
- type: integer
- type: array
- solves:
- items:
- $ref: '#/definitions/api.UserSolveResp'
- type: array
- solvesNumber:
- example: 100
- type: integer
- status:
- example: deployed
- type: string
- type: object
- api.ChallengePreviewResp:
- properties:
- category:
- example: web
- type: string
- description:
- type: string
- hints:
- items:
- type: string
- type: array
- name:
- example: Web Challenge
- type: string
- points:
- example: 50
- type: integer
- ports:
- items:
- type: integer
- type: array
- type: object
- api.ChallengeSolveResp:
- properties:
- category:
- example: web
- type: string
- id:
- example: 4
- type: integer
- name:
- example: Web Challenge
- type: string
- points:
- example: 50
- type: integer
- solvedAt:
- type: string
- type: object
- api.ChallengeStatusResp:
- properties:
- name:
- example: Web Challenge
- type: string
- status:
- example: deployed
- type: string
- updated_at:
- example: "2018-12-31T22:20:08.948096189+05:30"
- type: string
- type: object
- api.CompetitionInfoResp:
- properties:
- about:
- example: This is a CTF competition
- type: string
- ending_time:
- type: string
- logo_url:
- type: string
- name:
- example: fristonio
- type: string
- prizes:
- example: 1st and 2nd place winners will get $10K
- type: string
- starting_time:
- type: string
- timezone:
- type: string
- type: object
- api.HTTPAuthorizeResp:
- properties:
- message:
- example: Response message
- type: string
- role:
- example: author
- type: string
- token:
- example: YOUR_AUTHENTICATION_TOKEN
- type: string
- type: object
- api.HTTPErrorResp:
- properties:
- error:
- example: Error occured while veifying the challenge.
- type: string
- type: object
- api.HTTPPlainResp:
- properties:
- message:
- example: Messsage in response to your request
- type: string
- type: object
- api.LogsInfoResp:
- properties:
- stderr:
- example: '[ERROR] Challenge deployment failed.'
- type: string
- stdout:
- example: '[INFO] Challenge is starting to deploy'
- type: string
- type: object
- api.PortsInUseResp:
- properties:
- port_max_value:
- example: 20000
- type: integer
- port_min_value:
- example: 10000
- type: integer
- ports_in_use:
- items:
- type: integer
- type: array
- type: object
- api.SubmissionResp:
- properties:
- category:
- example: web
- type: string
- chall_id:
- example: 3
- type: integer
- name:
- example: Web Challenge
- type: string
- points:
- example: 50
- type: integer
- solvedAt:
- type: string
- user_id:
- example: 3
- type: integer
- username:
- example: fristonio
- type: string
- flag:
- example: flag{@#$}
- type: string
- type: object
- api.UserResp:
- properties:
- challenges:
- items:
- $ref: '#/definitions/api.ChallengeSolveResp'
- type: array
- email:
- example: fristonio@gmail.com
- type: string
- id:
- example: 5
- type: integer
- rank:
- example: 15
- type: integer
- role:
- example: author
- type: string
- score:
- example: 750
- type: integer
- status:
- example: 0
- type: integer
- username:
- example: CTF is live now!
- type: string
- type: object
- api.UserSolveResp:
- properties:
- id:
- example: 5
- type: integer
- solvedAt:
- type: string
- username:
- example: fristonio
- type: string
- type: object
- api.UsersStatisticsResp:
- properties:
- banned_users:
- type: integer
- total_registered_users:
- example: 120
- type: integer
- unbanned_users:
- type: integer
- type: object
-host: beast.sdslabs.co
-info:
- contact:
- email: contact.sdslabs.co.in
- name: SDSLabs
- url: https://chat.sdslabs.co
- description: Beast the automatic deployment tool for backdoor
- license:
- name: Apache 2.0
- url: http://www.apache.org/licenses/LICENSE-2.0.html
- title: Beast API
- version: "1.0"
-paths:
- /api/admin/statistics:
- get:
- consumes:
- - application/json
- description: returns various information about the competition which are used
- to control competition
- parameters:
- - description: Bearer
- in: header
- name: Authorization
- required: true
- type: string
- produces:
- - application/json
- responses:
- "200":
- description: OK
- schema:
- $ref: '#/definitions/api.CompetitionInfoResp'
- "400":
- description: Bad Request
- schema:
- $ref: '#/definitions/api.HTTPErrorResp'
- summary: returns competition info
- tags:
- - info
- /api/admin/users/:action/:id:
- post:
- consumes:
- - application/json
- description: Ban/unban a user based on his user id. This operation can only
- be done by admins
- parameters:
- - description: Action to perform ban/unban
- in: query
- name: action
- required: true
- type: string
- - description: Id of user
- in: query
- name: id
- required: true
- type: string
- produces:
- - application/json
- responses:
- "200":
- description: OK
- schema:
- $ref: '#/definitions/api.ChallengeStatusResp'
- "400":
- description: Bad Request
- schema:
- $ref: '#/definitions/api.HTTPPlainResp'
- "500":
- description: Internal Server Error
- schema:
- $ref: '#/definitions/api.HTTPPlainResp'
- summary: Ban/Unban a user based on his id and the action provided.
- tags:
- - admin
- /api/config/competition-info:
- post:
- consumes:
- - application/json
- description: Populates beast gobal config map by reparsing the config file $HOME/.beast/config.toml.
- parameters:
- - description: Competition Name
- in: formData
- name: name
- required: true
- type: string
- - description: Some information about competition
- in: formData
- name: about
- required: true
- type: string
- - description: Competitions Prizes for the winners
- in: formData
- name: prizes
- type: string
- - description: Competition's starting time
- in: formData
- name: starting_time
- required: true
- type: string
- - description: Competition's ending time
- in: formData
- name: ending_time
- required: true
- type: string
- - description: Competition's timezone
- in: formData
- name: timezone
- required: true
- type: string
- - description: Competition's logo
- in: formData
- name: logo
- type: file
- produces:
- - application/json
- responses:
- "200":
- description: OK
- schema:
- $ref: '#/definitions/api.HTTPPlainResp'
- "400":
- description: Bad Request
- schema:
- $ref: '#/definitions/api.HTTPPlainResp'
- "500":
- description: Internal Server Error
- schema:
- $ref: '#/definitions/api.HTTPErrorResp'
- summary: Updates competition info in the beast global configuration, located
- at ~/.beast/config.toml.
- tags:
- - config
- /api/config/reload/:
- patch:
- consumes:
- - application/json
- description: Populates beast gobal config map by reparsing the config file $HOME/.beast/config.toml.
- parameters:
- - description: Bearer
- in: header
- name: Authorization
- required: true
- type: string
- produces:
- - application/json
- responses:
- "200":
- description: OK
- schema:
- $ref: '#/definitions/api.HTTPPlainResp'
- "400":
- description: Bad Request
- schema:
- $ref: '#/definitions/api.HTTPPlainResp'
- summary: Reloads any changes in beast global configuration, located at ~/.beast/config.toml.
- tags:
- - config
- /api/info/challenge/info:
- get:
- consumes:
- - application/json
- description: Returns all information about the challenges by the challenge name.
- parameters:
- - description: Bearer
- in: header
- name: Authorization
- required: true
- type: string
- - description: Name of challenge
- in: query
- name: name
- required: true
- type: string
- produces:
- - application/json
- responses:
- "200":
- description: OK
- schema:
- $ref: '#/definitions/api.ChallengeInfoResp'
- "400":
- description: Bad Request
- schema:
- $ref: '#/definitions/api.HTTPErrorResp'
- "404":
- description: Not Found
- schema:
- $ref: '#/definitions/api.HTTPErrorResp'
- "500":
- description: Internal Server Error
- schema:
- $ref: '#/definitions/api.HTTPErrorResp'
- summary: Returns all information about the challenges.
- tags:
- - info
- /api/info/challenges:
- get:
- consumes:
- - application/json
- description: Returns information about all the challenges present in the database
- with and without filters.
- parameters:
- - description: Filter parameter by which challenges are filtered
- in: query
- name: filter
- type: string
- - description: Value of filtered parameter
- in: query
- name: value
- type: string
- - description: Bearer
- in: header
- name: Authorization
- required: true
- type: string
- produces:
- - application/json
- responses:
- "200":
- description: OK
- schema:
- $ref: '#/definitions/api.ChallengeInfoResp'
- "400":
- description: Bad Request
- schema:
- $ref: '#/definitions/api.HTTPErrorResp'
- "500":
- description: Internal Server Error
- schema:
- $ref: '#/definitions/api.HTTPErrorResp'
- summary: Returns information about all challenges with and without filters.
- tags:
- - info
- /api/info/images/available:
- get:
- consumes:
- - application/json
- description: Returns all the available base images which can be used for challenge
- creation as the base OS for challenge.
- parameters:
- - description: Bearer
- in: header
- name: Authorization
- required: true
- type: string
- produces:
- - application/json
- responses:
- "200":
- description: OK
- schema:
- $ref: '#/definitions/api.AvailableImagesResp'
- summary: Gives all the base images that can be used while creating a beast challenge,
- this is a constant specified in beast global config
- tags:
- - info
- /api/info/logs:
- get:
- consumes:
- - application/json
- description: Gives container logs for a particular challenge, useful for debugging
- purposes.
- parameters:
- - description: Bearer
- in: header
- name: Authorization
- required: true
- type: string
- - description: The name of the challenge to get the logs for.
- in: query
- name: challenge
- type: string
- produces:
- - application/json
- responses:
- "200":
- description: OK
- schema:
- $ref: '#/definitions/api.LogsInfoResp'
- "400":
- description: Bad Request
- schema:
- $ref: '#/definitions/api.HTTPPlainResp'
- "500":
- description: Internal Server Error
- schema:
- $ref: '#/definitions/api.HTTPPlainResp'
- summary: Handles route related to logs handling of container
- tags:
- - info
- /api/info/ports/used:
- get:
- consumes:
- - application/json
- description: Returns the ports in use by beast, which cannot be used in creating
- a new challenge..
- parameters:
- - description: Bearer
- in: header
- name: Authorization
- required: true
- type: string
- produces:
- - application/json
- responses:
- "200":
- description: OK
- schema:
- $ref: '#/definitions/api.PortsInUseResp'
- summary: Returns ports in use by beast by looking in the hack git repository,
- also returns min and max value of port allowed while specifying in beast challenge
- config.
- tags:
- - info
- /api/info/submissions:
- get:
- consumes:
- - application/json
- description: Handles submissions made by the user
- parameters:
- - description: Bearer
- in: header
- name: Authorization
- required: true
- type: string
- produces:
- - application/json
- responses:
- "200":
- description: OK
- schema:
- $ref: '#/definitions/api.SubmissionResp'
- "500":
- description: Internal Server Error
- schema:
- $ref: '#/definitions/api.HTTPErrorResp'
- summary: Handles submissions made by the user
- tags:
- - info
- /api/info/user:
- get:
- consumes:
- - application/json
- description: Returns user info based on userId
- parameters:
- - description: Bearer
- in: header
- name: Authorization
- required: true
- type: string
- - description: User's id
- in: formData
- name: value
- type: string
- - description: username
- in: query
- name: value
- type: string
- produces:
- - application/json
- responses:
- "200":
- description: OK
- schema:
- $ref: '#/definitions/api.UserResp'
- "400":
- description: Bad Request
- schema:
- $ref: '#/definitions/api.HTTPErrorResp'
- "500":
- description: Internal Server Error
- schema:
- $ref: '#/definitions/api.HTTPErrorResp'
- summary: Returns user info
- tags:
- - info
- /api/info/user/available:
- get:
- consumes:
- - application/json
- description: Returns all available user's info
- parameters:
- - description: Bearer
- in: header
- name: Authorization
- required: true
- type: string
- produces:
- - application/json
- responses:
- "200":
- description: OK
- schema:
- $ref: '#/definitions/api.UserResp'
- "404":
- description: Not Found
- schema:
- $ref: '#/definitions/api.HTTPErrorResp'
- "500":
- description: Internal Server Error
- schema:
- $ref: '#/definitions/api.HTTPErrorResp'
- summary: Returns all user's info
- tags:
- - info
- /api/manage/challenge/:
- post:
- consumes:
- - application/json
- description: Handles challenge management routes with actions which includes
- - DEPLOY, UNDEPLOY, PURGE.
- parameters:
- - description: Name of the challenge to be managed, here name is the unique
- identifier for challenge
- in: query
- name: name
- required: true
- type: string
- - description: Action for the challenge
- in: query
- name: action
- required: true
- type: string
- produces:
- - application/json
- responses:
- "200":
- description: OK
- schema:
- $ref: '#/definitions/api.HTTPPlainResp'
- "400":
- description: Bad Request
- schema:
- $ref: '#/definitions/api.HTTPPlainResp'
- summary: Handles challenge management actions.
- tags:
- - manage
- /api/manage/challenge/upload:
- post:
- consumes:
- - application/json
- description: Handles the challenge management from a challenge in tar file.
- Currently prepare the tar file
- parameters:
- - description: .tar file to be uploaded to fetch challenge info
- in: formData
- name: file
- required: true
- type: file
- produces:
- - application/json
- responses:
- "200":
- description: OK
- schema:
- $ref: '#/definitions/api.ChallengePreviewResp'
- "400":
- description: Bad Request
- schema:
- $ref: '#/definitions/api.HTTPErrorResp'
- "500":
- description: Internal Server Error
- schema:
- $ref: '#/definitions/api.HTTPErrorResp'
- summary: Untar and fetch info from beast.toml file in challenge
- tags:
- - manage
- /api/manage/commit/:
- post:
- consumes:
- - application/json
- description: Validates challenge configuration for deployment.
- parameters:
- - description: Name of the challenge to verify the deployment configuration
- for.
- in: query
- name: challenge
- required: true
- type: string
- produces:
- - application/json
- responses:
- "200":
- description: OK
- schema:
- $ref: '#/definitions/api.HTTPErrorResp'
- summary: Validates the configuration of the challenge and tells if challenge
- can be deployed or not.
- tags:
- - manage
- /api/manage/deploy/local:
- post:
- consumes:
- - application/json
- description: Handles deployment of a challenge using the absolute directory
- path
- parameters:
- - description: Challenge Directory
- in: query
- name: challenge_dir
- required: true
- type: string
- produces:
- - application/json
- responses:
- "200":
- description: OK
- schema:
- $ref: '#/definitions/api.HTTPPlainResp'
- "400":
- description: Bad Request
- schema:
- $ref: '#/definitions/api.HTTPPlainResp'
- "406":
- description: Not Acceptable
- schema:
- $ref: '#/definitions/api.HTTPPlainResp'
- summary: Deploy a local challenge using the path provided in the post parameter
- tags:
- - manage
- /api/manage/multiple/:action:
- post:
- consumes:
- - application/json
- description: Handles challenge management routes for multiple the challenges
- with actions which includes - DEPLOY, UNDEPLOY.
- parameters:
- - description: Action for the challenge
- in: query
- name: action
- required: true
- type: string
- - description: Tag for a group of challenges
- in: query
- name: tag
- type: string
- produces:
- - application/json
- responses:
- "200":
- description: OK
- schema:
- $ref: '#/definitions/api.HTTPPlainResp'
- "400":
- description: Bad Request
- schema:
- $ref: '#/definitions/api.HTTPPlainResp'
- summary: Handles challenge management actions for multiple challenges.
- tags:
- - manage
- /api/manage/schedule/:action:
- post:
- consumes:
- - application/json
- description: Handles scheduleing of challenge action to executed at some later
- point of time
- parameters:
- - description: Bearer
- in: header
- name: Authorization
- required: true
- type: string
- - description: Action for the underlying challenge in context
- in: query
- name: action
- required: true
- type: string
- - description: The name of the challenge to schedule the action for.
- in: query
- name: challenge
- type: string
- - description: Tag corresponding to challenges in context, optional if challenge
- name is provided
- in: query
- name: tags
- type: string
- - description: Timestamp at which the challenge should be scheduled should be
- a unix timestamp string.
- in: query
- name: at
- type: string
- - description: Time after which the action on the selector should be executed
- should be of duration format as in '1m20s' etc.
- in: query
- name: after
- type: string
- produces:
- - application/json
- responses:
- "200":
- description: OK
- schema:
- $ref: '#/definitions/api.HTTPPlainResp'
- "400":
- description: Bad Request
- schema:
- $ref: '#/definitions/api.HTTPPlainResp'
- summary: Schedule an action(deploy, undeploy, purge etc.) on a particular challenge
- tags:
- - manage
- /api/manage/static/:action:
- post:
- consumes:
- - application/json
- description: Handles beast static content serving container routes.
- parameters:
- - description: Action to apply on the beast static content provider
- in: query
- name: action
- required: true
- type: string
- produces:
- - application/json
- responses:
- "200":
- description: OK
- schema:
- $ref: '#/definitions/api.HTTPPlainResp'
- "400":
- description: Bad Request
- schema:
- $ref: '#/definitions/api.HTTPPlainResp'
- summary: Handles route related to beast static content serving container, takes
- action as route parameter and perform that action
- tags:
- - manage
- /api/notification/add:
- post:
- consumes:
- - application/json
- description: Adds notifications
- parameters:
- - description: Title of notification to be added
- in: formData
- name: title
- required: true
- type: string
- - description: Description for the notification to be added
- in: formData
- name: desc
- required: true
- type: string
- produces:
- - application/json
- responses:
- "200":
- description: OK
- schema:
- $ref: '#/definitions/api.HTTPPlainResp'
- "400":
- description: Bad Request
- schema:
- $ref: '#/definitions/api.HTTPErrorResp'
- "500":
- description: Internal Server Error
- schema:
- $ref: '#/definitions/api.HTTPErrorResp'
- summary: Adds notifications
- tags:
- - notification
- /api/notification/available:
- post:
- consumes:
- - application/json
- description: Fetch all the notifications from database
- produces:
- - application/json
- responses:
- "200":
- description: OK
- schema:
- $ref: '#/definitions/api.HTTPPlainResp'
- "500":
- description: Internal Server Error
- schema:
- $ref: '#/definitions/api.HTTPErrorResp'
- summary: Fetch available notifications
- tags:
- - notification
- /api/notification/delete:
- post:
- consumes:
- - application/json
- description: Removes notifications
- parameters:
- - description: Title of notification
- in: formData
- name: id
- required: true
- type: string
- produces:
- - application/json
- responses:
- "200":
- description: OK
- schema:
- $ref: '#/definitions/api.HTTPPlainResp'
- "400":
- description: Bad Request
- schema:
- $ref: '#/definitions/api.HTTPErrorResp'
- "500":
- description: Internal Server Error
- schema:
- $ref: '#/definitions/api.HTTPErrorResp'
- summary: Removes notifications
- tags:
- - notification
- /api/notification/update:
- post:
- consumes:
- - application/json
- description: Updates any changes in the notifications
- parameters:
- - description: Title of notification
- in: formData
- name: id
- required: true
- type: string
- - description: Title of notification
- in: formData
- name: title
- required: true
- type: string
- - description: Description for the notification to be changed
- in: formData
- name: desc
- required: true
- type: string
- produces:
- - application/json
- responses:
- "200":
- description: OK
- schema:
- $ref: '#/definitions/api.HTTPPlainResp'
- "400":
- description: Bad Request
- schema:
- $ref: '#/definitions/api.HTTPErrorResp'
- "500":
- description: Internal Server Error
- schema:
- $ref: '#/definitions/api.HTTPErrorResp'
- summary: Updates notifications
- tags:
- - notification
- /api/remote/reset/:
- post:
- consumes:
- - application/json
- description: Resets local copy of remote git directory, it first deletes the
- existing directory and then clone from the remote again.
- parameters:
- - description: Bearer
- in: header
- name: Authorization
- required: true
- type: string
- produces:
- - application/json
- responses:
- "200":
- description: OK
- schema:
- $ref: '#/definitions/api.HTTPPlainResp'
- "500":
- description: Internal Server Error
- schema:
- $ref: '#/definitions/api.HTTPPlainResp'
- summary: Resets beast local copy of remote git repository.
- tags:
- - remote
- /api/remote/sync/:
- post:
- consumes:
- - application/json
- description: Syncs beasts local challenges database with the remote git repository(hack)
- the local copy of the challenge database is located at $HOME/.beast/remote/$REMOTE_NAME.
- parameters:
- - description: Bearer
- in: header
- name: Authorization
- required: true
- type: string
- produces:
- - application/json
- responses:
- "200":
- description: OK
- schema:
- $ref: '#/definitions/api.HTTPPlainResp'
- "500":
- description: Internal Server Error
- schema:
- $ref: '#/definitions/api.HTTPPlainResp'
- summary: Syncs beast's local copy of remote git repository for challenges.
- tags:
- - remote
- /api/status/all/:filter:
- get:
- consumes:
- - application/json
- description: This returns the challenges in the status provided, along with
- their name and last updated time.
- parameters:
- - description: Status type to filter with, if none specified then all
- in: query
- name: filter
- required: true
- type: string
- - description: Bearer
- in: header
- name: Authorization
- required: true
- type: string
- produces:
- - application/json
- responses:
- "200":
- description: OK
- schema:
- items:
- $ref: '#/definitions/api.ChallengeStatusResp'
- type: array
- "400":
- description: Bad Request
- schema:
- $ref: '#/definitions/api.HTTPPlainResp'
- "500":
- description: Internal Server Error
- schema:
- $ref: '#/definitions/api.HTTPPlainResp'
- summary: Returns challenge deployment status from the beast database for the
- challenges which matches the stauts according to filter.
- tags:
- - status
- /api/status/challenge/:name:
- get:
- consumes:
- - application/json
- description: Returns challenge deployment status from the beast database, for
- those challenges which are not present a status value NA is returned.
- parameters:
- - description: Name of the challenge
- in: query
- name: name
- required: true
- type: string
- - description: Bearer
- in: header
- name: Authorization
- required: true
- type: string
- produces:
- - application/json
- responses:
- "200":
- description: OK
- schema:
- $ref: '#/definitions/api.ChallengeStatusResp'
- "400":
- description: Bad Request
- schema:
- $ref: '#/definitions/api.HTTPPlainResp'
- "500":
- description: Internal Server Error
- schema:
- $ref: '#/definitions/api.HTTPPlainResp'
- summary: Returns challenge deployment status from the beast database.
- tags:
- - status
- /api/submit/challenge:
- post:
- consumes:
- - application/json
- description: Returns success or error response based on the flag submitted.
- Also, the flag will not be submitted if it was previously submitted
- parameters:
- - description: Name of challenge
- in: formData
- name: chall_id
- required: true
- type: string
- - description: Flag for the challenge
- in: formData
- name: flag
- required: true
- type: string
- produces:
- - application/json
- responses:
- "200":
- description: OK
- schema:
- $ref: '#/definitions/api.ChallengeStatusResp'
- "400":
- description: Bad Request
- schema:
- $ref: '#/definitions/api.HTTPPlainResp'
- "401":
- description: Unauthorized
- schema:
- $ref: '#/definitions/api.HTTPPlainResp'
- "500":
- description: Internal Server Error
- schema:
- $ref: '#/definitions/api.HTTPPlainResp'
- summary: Verifies and creates an entry in the database for successful submission
- of flag for a challenge.
- tags:
- - Submit
- /auth/login:
- post:
- consumes:
- - application/json
- description: JWT can be received by signing in
- parameters:
- - description: Username
- in: formData
- name: username
- required: true
- type: string
- - description: Password
- in: formData
- name: password
- required: true
- type: string
- produces:
- - application/json
- responses:
- "200":
- description: OK
- schema:
- $ref: '#/definitions/api.HTTPAuthorizeResp'
- "400":
- description: Bad Request
- schema:
- $ref: '#/definitions/api.HTTPPlainResp'
- "401":
- description: Unauthorized
- schema:
- $ref: '#/definitions/api.HTTPPlainResp'
- "403":
- description: Forbidden
- schema:
- $ref: '#/definitions/api.HTTPPlainResp'
- summary: Handles signin and token production
- tags:
- - auth
- /auth/register:
- post:
- description: Signup route for the user
- parameters:
- - description: User's name
- in: formData
- name: name
- type: string
- - description: Username
- in: formData
- name: username
- required: true
- type: string
- - description: Password
- in: formData
- name: password
- required: true
- type: string
- - description: User's email id
- in: formData
- name: email
- required: true
- type: string
- - description: User's ssh-key
- in: formData
- name: ssh-key
- type: string
- produces:
- - application/json
- responses:
- "200":
- description: OK
- schema:
- $ref: '#/definitions/api.HTTPPlainResp'
- "400":
- description: Bad Request
- schema:
- $ref: '#/definitions/api.HTTPPlainResp'
- "406":
- description: Not Acceptable
- schema:
- $ref: '#/definitions/api.HTTPPlainResp'
- summary: Signup for the user
- tags:
- - auth
- /auth/reset-password:
- post:
- description: Resets password for the user
- parameters:
- - description: New Password
- in: formData
- name: new_pass
- required: true
- type: string
- produces:
- - application/json
- responses:
- "200":
- description: OK
- schema:
- $ref: '#/definitions/api.HTTPPlainResp'
- "401":
- description: Unauthorized
- schema:
- $ref: '#/definitions/api.HTTPPlainResp'
- "500":
- description: Internal Server Error
- schema:
- $ref: '#/definitions/api.HTTPPlainResp'
- summary: Resets password for the user
- tags:
- - auth
-securityDefinitions:
- ApiKeyAuth:
- in: header
- name: Authorization
- type: apiKey
-swagger: "2.0"
diff --git a/api/exec.go b/api/exec.go
new file mode 100644
index 00000000..d0eb4470
--- /dev/null
+++ b/api/exec.go
@@ -0,0 +1,184 @@
+package api
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/sdslabs/beastv4/core"
+ "github.com/sdslabs/beastv4/core/cache"
+ "github.com/sdslabs/beastv4/core/config"
+ "github.com/sdslabs/beastv4/core/database"
+ "github.com/sdslabs/beastv4/core/manager"
+ "github.com/sdslabs/beastv4/pkg/auth"
+ "github.com/sdslabs/beastv4/pkg/cr"
+ "github.com/sdslabs/beastv4/pkg/remoteManager"
+ "gorm.io/gorm"
+)
+
+const (
+ maxExecRequestBytes = 32 << 10
+ maxExecArguments = 64
+ maxExecArgumentBytes = 16 << 10
+ defaultExecTimeout = 30
+ maxExecTimeout = 300
+)
+
+type ExecChallengeRequest struct {
+ Command []string `json:"command"`
+ InstanceID string `json:"instance_id,omitempty"`
+ TimeoutSeconds int `json:"timeout_seconds,omitempty"`
+}
+
+type ExecChallengeResponse struct {
+ Stdout string `json:"stdout"`
+ Stderr string `json:"stderr"`
+ ExitCode int `json:"exit_code"`
+ Truncated bool `json:"truncated"`
+}
+
+func (request *ExecChallengeRequest) validate() error {
+ if len(request.Command) == 0 || len(request.Command) > maxExecArguments {
+ return errors.New("command must contain between 1 and 64 arguments")
+ }
+ totalBytes := 0
+ for index, argument := range request.Command {
+ if index == 0 && argument == "" {
+ return errors.New("command executable cannot be empty")
+ }
+ if strings.IndexByte(argument, 0) >= 0 {
+ return errors.New("command arguments cannot contain NUL bytes")
+ }
+ totalBytes += len(argument)
+ }
+ if totalBytes > maxExecArgumentBytes {
+ return errors.New("command arguments exceed 16 KiB")
+ }
+ if request.TimeoutSeconds == 0 {
+ request.TimeoutSeconds = defaultExecTimeout
+ }
+ if request.TimeoutSeconds < 1 || request.TimeoutSeconds > maxExecTimeout {
+ return errors.New("timeout_seconds must be between 1 and 300")
+ }
+ return nil
+}
+
+func userCanExecChallenge(user database.User, challenge database.Challenge, maintainer bool) bool {
+ if user.Status != 0 {
+ return false
+ }
+ if user.Role == core.USER_ROLES["admin"] {
+ return true
+ }
+ if user.Role != core.USER_ROLES["author"] && user.Role != core.USER_ROLES["maintainer"] {
+ return false
+ }
+ return challenge.AuthorID == user.ID || maintainer
+}
+
+// @Summary Execute an argument-vector command in an owned challenge container
+// @Tags manage
+// @Accept json
+// @Produce json
+// @Param name path string true "Challenge name"
+// @Param request body api.ExecChallengeRequest true "Bounded exec request"
+// @Security ApiKeyAuth
+// @Success 200 {object} api.ExecChallengeResponse
+// @Failure 400 {object} api.HTTPErrorResp
+// @Failure 403 {object} api.HTTPErrorResp
+// @Router /api/manage/challenge/{name}/exec [post]
+func execChallengeHandler(c *gin.Context) {
+ c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxExecRequestBytes)
+ var request ExecChallengeRequest
+ if err := c.ShouldBindJSON(&request); err != nil {
+ c.JSON(http.StatusBadRequest, HTTPErrorResp{Error: "invalid exec request"})
+ return
+ }
+ if err := request.validate(); err != nil {
+ c.JSON(http.StatusBadRequest, HTTPErrorResp{Error: err.Error()})
+ return
+ }
+
+ claimsValue, exists := c.Get("authClaims")
+ claims, validClaims := claimsValue.(*auth.CustomClaims)
+ if !exists || !validClaims {
+ c.JSON(http.StatusUnauthorized, HTTPErrorResp{Error: "verified authentication claims are required"})
+ return
+ }
+ user, err := database.QueryFirstUserEntry("username", claims.User)
+ if err != nil || user.ID == 0 {
+ c.JSON(http.StatusForbidden, HTTPErrorResp{Error: "user is not authorized for container execution"})
+ return
+ }
+ challenge, err := database.QueryFirstChallengeEntry("name", c.Param("name"))
+ if err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ c.JSON(http.StatusNotFound, HTTPErrorResp{Error: "challenge not found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, HTTPErrorResp{Error: "failed to query challenge"})
+ return
+ }
+ maintainer, err := database.IsChallengeMaintainer(user.ID, challenge.ID)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, HTTPErrorResp{Error: "failed to authorize challenge access"})
+ return
+ }
+ if !userCanExecChallenge(user, challenge, maintainer) {
+ c.JSON(http.StatusForbidden, HTTPErrorResp{Error: "challenge execution access denied"})
+ return
+ }
+ if challenge.Status != core.DEPLOY_STATUS["deployed"] || challenge.Type == core.STATIC_CHALLENGE_TYPE_NAME {
+ c.JSON(http.StatusConflict, HTTPErrorResp{Error: "challenge does not have a running container"})
+ return
+ }
+
+ containerID := challenge.ContainerId
+ serverName := challenge.ServerDeployed
+ if challenge.Instanced {
+ if request.InstanceID == "" {
+ c.JSON(http.StatusBadRequest, HTTPErrorResp{Error: "instance_id is required for an instanced challenge"})
+ return
+ }
+ instance, err := cache.GetInstance(request.InstanceID)
+ if err != nil || instance == nil || instance.ChallengeName != challenge.Name {
+ c.JSON(http.StatusNotFound, HTTPErrorResp{Error: "challenge instance not found"})
+ return
+ }
+ containerID = instance.ContainerID
+ serverName = instance.ServerDeployed
+ }
+ if containerID == "" {
+ c.JSON(http.StatusConflict, HTTPErrorResp{Error: "challenge container is unavailable"})
+ return
+ }
+
+ ctx, cancel := context.WithTimeout(c.Request.Context(), time.Duration(request.TimeoutSeconds)*time.Second)
+ defer cancel()
+ var result cr.ExecResult
+ server, found := config.Cfg.AvailableServers[serverName]
+ if !found || !server.Active {
+ c.JSON(http.StatusBadGateway, HTTPErrorResp{Error: "challenge worker is unavailable"})
+ return
+ }
+ _ = manager.LogTransaction(challenge.Name, "EXEC", c.GetHeader("Authorization"))
+ if config.Cfg.UseLocalDockerDaemon(serverName) {
+ result, err = cr.ExecContainer(ctx, containerID, request.Command, cr.DefaultExecOutputLimit)
+ } else {
+ result, err = remoteManager.ExecContainerRemote(ctx, server, containerID, request.Command, cr.DefaultExecOutputLimit)
+ }
+ if err != nil {
+ if errors.Is(err, context.DeadlineExceeded) {
+ c.JSON(http.StatusGatewayTimeout, HTTPErrorResp{Error: "container command timed out"})
+ } else {
+ c.JSON(http.StatusBadGateway, HTTPErrorResp{Error: "container command failed to execute"})
+ }
+ return
+ }
+ c.JSON(http.StatusOK, ExecChallengeResponse{
+ Stdout: result.Stdout, Stderr: result.Stderr, ExitCode: result.ExitCode, Truncated: result.Truncated,
+ })
+}
diff --git a/api/exec_test.go b/api/exec_test.go
new file mode 100644
index 00000000..205c0b5d
--- /dev/null
+++ b/api/exec_test.go
@@ -0,0 +1,47 @@
+package api
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/sdslabs/beastv4/core"
+ "github.com/sdslabs/beastv4/core/database"
+ "github.com/sdslabs/beastv4/pkg/auth"
+ "gorm.io/gorm"
+)
+
+func TestExecChallengeRequestValidation(t *testing.T) {
+ tests := []ExecChallengeRequest{
+ {},
+ {Command: []string{""}},
+ {Command: []string{"echo", string([]byte{'a', 0, 'b'})}},
+ {Command: []string{"echo"}, TimeoutSeconds: maxExecTimeout + 1},
+ {Command: []string{strings.Repeat("a", maxExecArgumentBytes+1)}},
+ }
+ for _, request := range tests {
+ if err := request.validate(); err == nil {
+ t.Fatalf("expected invalid request: %+v", request)
+ }
+ }
+
+ valid := ExecChallengeRequest{Command: []string{"sh", "-lc", "id"}}
+ if err := valid.validate(); err != nil {
+ t.Fatal(err)
+ }
+ if valid.TimeoutSeconds != defaultExecTimeout {
+ t.Fatalf("default timeout = %d", valid.TimeoutSeconds)
+ }
+}
+
+func TestUserCanExecChallenge(t *testing.T) {
+ challenge := database.Challenge{AuthorID: 1}
+ if !userCanExecChallenge(database.User{Model: gorm.Model{ID: 1}, AuthModel: auth.AuthModel{Role: core.USER_ROLES["author"]}}, challenge, false) {
+ t.Fatal("challenge author was denied")
+ }
+ if !userCanExecChallenge(database.User{Model: gorm.Model{ID: 2}, AuthModel: auth.AuthModel{Role: core.USER_ROLES["maintainer"]}}, challenge, true) {
+ t.Fatal("challenge maintainer was denied")
+ }
+ if userCanExecChallenge(database.User{Model: gorm.Model{ID: 3}, AuthModel: auth.AuthModel{Role: core.USER_ROLES["contestant"]}}, challenge, true) {
+ t.Fatal("contestant was allowed")
+ }
+}
diff --git a/api/info.go b/api/info.go
index 175a52f9..6d88c3bb 100644
--- a/api/info.go
+++ b/api/info.go
@@ -1,12 +1,14 @@
package api
import (
+ "errors"
"fmt"
"net/http"
"path/filepath"
"sort"
"strconv"
"strings"
+ "sync"
"github.com/gin-gonic/gin"
"github.com/sdslabs/beastv4/core"
@@ -14,10 +16,10 @@ import (
cfg "github.com/sdslabs/beastv4/core/config"
"github.com/sdslabs/beastv4/core/database"
"github.com/sdslabs/beastv4/core/utils"
- coreUtils "github.com/sdslabs/beastv4/core/utils"
"github.com/sdslabs/beastv4/pkg/auth"
fileUtils "github.com/sdslabs/beastv4/utils"
log "github.com/sirupsen/logrus"
+ "gorm.io/gorm"
)
var (
@@ -27,8 +29,26 @@ var (
adminLeaderboardStale = true
graphCache []database.UserLeaderboardResp
graphCacheStale = true
+ leaderboardCacheMu sync.Mutex
)
+func markLeaderboardCachesStale() {
+ leaderboardCacheMu.Lock()
+ leaderboardStale = true
+ adminLeaderboardStale = true
+ graphCacheStale = true
+ leaderboardCacheMu.Unlock()
+}
+
+// @Summary Read or purchase a challenge hint
+// @Tags info
+// @Produce json
+// @Param hintID path int true "Hint ID"
+// @Security ApiKeyAuth
+// @Success 200 {object} api.HintResponse
+// @Failure 400 {object} api.HTTPErrorResp
+// @Router /api/info/hint/{hintID} [get]
+// @Router /api/info/hint/{hintID} [post]
func hintHandler(c *gin.Context) {
hintIDStr := c.Param("hintID")
@@ -48,7 +68,7 @@ func hintHandler(c *gin.Context) {
return
}
- username, err := coreUtils.GetUser(c.GetHeader("Authorization"))
+ username, err := utils.GetUser(c.GetHeader("Authorization"))
if err != nil {
c.JSON(http.StatusUnauthorized, HTTPErrorResp{
Error: "Unauthorized user",
@@ -140,18 +160,7 @@ func hintHandler(c *gin.Context) {
return
}
- oldScore := user.Score
- newScore := oldScore - hint.Points
- if newScore < 0 {
- newScore = 0
- }
-
- if len(adminLeaderboardCache) < core.LEADERBOARD_SIZE ||
- (len(adminLeaderboardCache) > 0 && oldScore >= adminLeaderboardCache[len(adminLeaderboardCache)-1].Score) {
- leaderboardStale = true
- graphCacheStale = true
- adminLeaderboardStale = true
- }
+ markLeaderboardCachesStale()
// Return the hint description after successfully taking it
c.JSON(http.StatusOK, HTTPPlainResp{
@@ -166,12 +175,12 @@ func hintHandler(c *gin.Context) {
// @Accept json
// @Produce json
// @Param Authorization header string true "Bearer"
-// @Param name query string true "Name of challenge"
-// @Success 200 {object} api.ChallengeInfoResp
+// @Param name path string true "Name of challenge"
+// @Success 200 {object} api.Challenge
// @Failure 400 {object} api.HTTPErrorResp
// @Failure 404 {object} api.HTTPErrorResp
// @Failure 500 {object} api.HTTPErrorResp
-// @Router /api/info/challenge/info [get]
+// @Router /api/info/challenge/{name} [get]
func challengeInfoHandler(c *gin.Context) {
name := c.Param("name")
if name == "" {
@@ -190,7 +199,7 @@ func challengeInfoHandler(c *gin.Context) {
}
authHeader := c.GetHeader("Authorization")
- username, err := coreUtils.GetUser(authHeader)
+ username, err := utils.GetUser(authHeader)
if err != nil {
c.JSON(http.StatusUnauthorized, HTTPErrorResp{
Error: "No Token Provided",
@@ -267,7 +276,12 @@ func challengeInfoHandler(c *gin.Context) {
MaxAttemptLimit: challenge.MaxAttemptLimit,
DeployedLink: challenge.ServerDeployed,
}
- if user.Role == core.USER_ROLES["contestant"] {
+ canViewSecret, err := canViewChallengeSecrets(&user, &challenge)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, HTTPErrorResp{Error: "DATABASE ERROR while checking challenge access."})
+ return
+ }
+ if !canViewSecret {
c.JSON(http.StatusOK, challengeInfo)
return
}
@@ -284,6 +298,16 @@ func challengeInfoHandler(c *gin.Context) {
}
}
+func canViewChallengeSecrets(user *database.User, challenge *database.Challenge) (bool, error) {
+ if user.Role == core.USER_ROLES["admin"] || challenge.AuthorID == user.ID {
+ return true, nil
+ }
+ if user.Role == core.USER_ROLES["contestant"] {
+ return false, nil
+ }
+ return database.IsChallengeMaintainer(user.ID, challenge.ID)
+}
+
// Returns metadata about all challenges with and without filters
// @Summary Returns metadata about all challenges with and without filters.
// @Description Returns information about all the challenges present in the database with and without filters.
@@ -293,7 +317,7 @@ func challengeInfoHandler(c *gin.Context) {
// @Param filter query string false "Filter parameter by which challenges are filtered"
// @Param value query string false "Value of filtered parameter"
// @Param Authorization header string true "Bearer"
-// @Success 200 {object} api.ChallengeInfoResp
+// @Success 200 {array} api.ChallengeMetadata
// @Failure 400 {object} api.HTTPErrorResp
// @Failure 500 {object} api.HTTPErrorResp
// @Router /api/info/challenges [get]
@@ -379,7 +403,7 @@ func challengesMetadataHandler(c *gin.Context) {
availableChallenges := make([]ChallengeMetadata, 0, len(challenges))
authHeader := c.GetHeader("Authorization")
- username, err := coreUtils.GetUser(authHeader)
+ username, err := utils.GetUser(authHeader)
if err != nil {
c.JSON(http.StatusUnauthorized, HTTPErrorResp{
Error: "No Token Provided",
@@ -461,7 +485,8 @@ func availableImagesHandler(c *gin.Context) {
// @Success 200 {object} api.LogsInfoResp
// @Failure 400 {object} api.HTTPPlainResp
// @Failure 500 {object} api.HTTPPlainResp
-// @Router /api/info/logs [get]
+// @Security ApiKeyAuth
+// @Router /api/manage/logs [get]
func challengeLogsHandler(c *gin.Context) {
chall := c.Query("challenge")
if chall == "" {
@@ -470,6 +495,9 @@ func challengeLogsHandler(c *gin.Context) {
})
return
}
+ if !authorizeChallengeManagement(c, chall, false) {
+ return
+ }
logs, err := utils.GetLogs(chall, false)
if err != nil {
@@ -491,14 +519,14 @@ func challengeLogsHandler(c *gin.Context) {
// @Accept json
// @Produce json
// @Param Authorization header string true "Bearer"
-// @Param value formData string false "User's id"
-// @Param value query string false "username"
+// @Param user_id query int false "User ID"
+// @Param username path string true "Username"
// @Success 200 {object} api.UserResp
// @Failure 400 {object} api.HTTPErrorResp
// @Failure 500 {object} api.HTTPErrorResp
-// @Router /api/info/user [get]
+// @Router /api/info/user/{username} [get]
func userInfoHandler(c *gin.Context) {
- userId := c.PostForm("user_id")
+ userId := c.Query("user_id")
username := c.Param("username")
if userId == "" && username == "" {
c.JSON(http.StatusBadRequest, HTTPErrorResp{
@@ -519,6 +547,10 @@ func userInfoHandler(c *gin.Context) {
user, err = database.QueryUserById(uint(id))
if err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ c.JSON(http.StatusNotFound, HTTPErrorResp{Error: "User not found"})
+ return
+ }
c.JSON(http.StatusInternalServerError, HTTPErrorResp{
Error: "DATABASE ERROR while processing the request.",
})
@@ -527,6 +559,10 @@ func userInfoHandler(c *gin.Context) {
} else {
user, err = database.QueryFirstUserEntry("username", username)
if err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ c.JSON(http.StatusNotFound, HTTPErrorResp{Error: "User not found"})
+ return
+ }
c.JSON(http.StatusInternalServerError, HTTPErrorResp{
Error: "DATABASE ERROR while processing the request.",
})
@@ -598,8 +634,11 @@ func userInfoHandler(c *gin.Context) {
// @Accept json
// @Produce json
// @Param Authorization header string true "Bearer"
-// @Param sort, order, filter
-// @Success 200 {object} api.UserResp
+// @Param sort query string false "Sort by username or score"
+// @Param order query string false "Score order: asc or desc"
+// @Param filter query string false "Filter by banned, active, or hidden"
+// @Param format query string false "Response format: json or csv"
+// @Success 200 {array} api.UsersResp
// @Failure 404 {object} api.HTTPErrorResp
// @Failure 500 {object} api.HTTPErrorResp
// @Router /api/info/users [get]
@@ -861,7 +900,7 @@ func getUsersStatisticsHandler(c *gin.Context) {
// @Param Authorization header string true "Bearer"
// @Success 200 {object} api.CompetitionInfoResp
// @Failure 400 {object} api.HTTPErrorResp
-// @Router /api/admin/statistics [get]
+// @Router /api/info/competition-info [get]
func competitionInfoHandler(c *gin.Context) {
competitionInfo, err := config.GetCompetitionInfo()
if err != nil {
@@ -893,7 +932,7 @@ func competitionInfoHandler(c *gin.Context) {
// @Param Authorization header string true "Bearer"
// @Success 200 {object} api.TagInfoResp
// @Failure 400 {object} api.HTTPErrorResp
-// @Router /api/admin/statistics [get]
+// @Router /api/info/tags [get]
func tagHandler(c *gin.Context) {
// Optimized: Query unique tags directly from the database
tags, err := database.QueryAllUniqueTags()
@@ -920,22 +959,38 @@ func tagHandler(c *gin.Context) {
// @Failure 500 {object} api.HTTPPlainResp
// @Router /api/info/download [get]
func serveAssets(c *gin.Context) {
- challenge := c.Query("challenge")
+ challengeName := c.Query("challenge")
assetName := c.Query("asset")
- challenge = filepath.Base(challenge)
- assetName = filepath.Base(assetName)
- if challenge == "" || assetName == "" {
+ if challengeName == "" || challengeName != filepath.Base(challengeName) || strings.Contains(challengeName, `\`) || assetName == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "challenge and asset parameters are required"})
return
}
- filepath := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_STAGING_DIR, challenge, core.BEAST_STATIC_FOLDER, assetName)
- err := fileUtils.ValidateFileExists(filepath)
- if err != nil {
+ if err, state := utils.CheckTime(); err != nil || state == 0 {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Incorrect file requested"})
+ return
+ }
+ challenge, err := database.QueryFirstChallengeEntry("name", challengeName)
+ if err != nil || !declaresAsset(challenge.Assets, assetName) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Incorrect file requested"})
return
}
- c.FileAttachment(filepath, assetName)
+ staticRoot := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_STAGING_DIR, challengeName, core.BEAST_STATIC_FOLDER)
+ assetPath, err := fileUtils.ResolvePathWithin(staticRoot, assetName)
+ if err != nil || fileUtils.ValidateFileExists(assetPath) != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Incorrect file requested"})
+ return
+ }
+ c.FileAttachment(assetPath, filepath.Base(assetName))
+
+}
+func declaresAsset(encodedAssets, requested string) bool {
+ for _, asset := range strings.Split(encodedAssets, core.DELIMITER) {
+ if asset == requested {
+ return true
+ }
+ }
+ return false
}
// This route returns the number of users in the databse with role=contestant
@@ -992,6 +1047,8 @@ func getLeaderboardHandler(c *gin.Context) {
}
if isLeaderboardFrozen {
if page == 1 {
+ leaderboardCacheMu.Lock()
+ defer leaderboardCacheMu.Unlock()
if leaderboardStale {
users, err := database.QueryTopUsersByFrozenScore(core.LEADERBOARD_SIZE)
if err != nil {
@@ -1050,6 +1107,8 @@ func getLeaderboardHandler(c *gin.Context) {
}
if page == 1 {
+ leaderboardCacheMu.Lock()
+ defer leaderboardCacheMu.Unlock()
if leaderboardStale {
users, err := database.QueryTopUsersByScore(core.LEADERBOARD_SIZE)
if err != nil {
@@ -1132,6 +1191,8 @@ func adminLeaderboardHandler(c *gin.Context) {
return
}
if page == 1 {
+ leaderboardCacheMu.Lock()
+ defer leaderboardCacheMu.Unlock()
if adminLeaderboardStale {
users, err := database.QueryTopUsersByScore(core.LEADERBOARD_SIZE)
if err != nil {
@@ -1209,8 +1270,7 @@ func freezeLeaderboardHandler(c *gin.Context) {
Message: "DATABASE ERROR while processing the request.",
})
}
- leaderboardStale = true
- graphCacheStale = true
+ markLeaderboardCachesStale()
c.JSON(http.StatusOK, HTTPPlainResp{
Message: "User leaderboard frozen successfully",
})
@@ -1235,8 +1295,7 @@ func unfreezeLeaderboardHandler(c *gin.Context) {
Message: "DATABASE ERROR while processing the request.",
})
}
- leaderboardStale = true
- graphCacheStale = true
+ markLeaderboardCachesStale()
c.JSON(http.StatusOK, HTTPPlainResp{
Message: "User leaderboard unfrozen successfully",
})
@@ -1253,9 +1312,9 @@ func unfreezeLeaderboardHandler(c *gin.Context) {
// @Success 200 {array} api.UserSolveResp
// @Failure 400 {object} api.HTTPErrorResp
// @Failure 500 {object} api.HTTPErrorResp
-// @Router /api/challenges/{challenge_id}/attempts [get]
+// @Router /api/info/submissions/challenge/{challenge_id} [get]
func getChallengeAttempts(c *gin.Context) {
- username, err := coreUtils.GetUser(c.GetHeader("Authorization"))
+ username, err := utils.GetUser(c.GetHeader("Authorization"))
if err != nil {
c.JSON(http.StatusUnauthorized, HTTPErrorResp{
Error: "Unauthorized user",
@@ -1271,8 +1330,6 @@ func getChallengeAttempts(c *gin.Context) {
return
}
- isContestant := queryingUser.Role == core.USER_ROLES["contestant"]
-
challengeIDStr := c.Param("challenge_id")
challengeID, err := strconv.ParseUint(challengeIDStr, 10, 64)
if err != nil {
@@ -1297,9 +1354,10 @@ func getChallengeAttempts(c *gin.Context) {
return
}
- challengeTags := make([]string, len(challenge[0].Tags))
- for index, tag := range challenge[0].Tags {
- challengeTags[index] = tag.TagName
+ canViewSecrets, err := canViewChallengeSecrets(&queryingUser, &challenge[0])
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, HTTPErrorResp{Error: "DATABASE ERROR while checking challenge access."})
+ return
}
attempts, err := database.QueryChallAttempts(challengeID)
@@ -1313,7 +1371,7 @@ func getChallengeAttempts(c *gin.Context) {
resp := make([]SubmissionResp, 0, len(attempts))
for _, attempt := range attempts {
- if isContestant && (!attempt.Correct || attempt.Cheating) {
+ if !canViewSecrets && (!attempt.Correct || attempt.Cheating) {
continue
}
@@ -1326,7 +1384,7 @@ func getChallengeAttempts(c *gin.Context) {
Success: attempt.Correct,
}
- if !isContestant {
+ if canViewSecrets {
submissionResp.Flag = attempt.Flag
submissionResp.Cheating = attempt.Cheating
}
@@ -1350,7 +1408,7 @@ func getChallengeAttempts(c *gin.Context) {
// @Failure 500 {object} api.HTTPErrorResp
// @Router /api/info/submissions/user/{user_id} [get]
func getUserAttempts(c *gin.Context) {
- username, err := coreUtils.GetUser(c.GetHeader("Authorization"))
+ username, err := utils.GetUser(c.GetHeader("Authorization"))
if err != nil {
c.JSON(http.StatusUnauthorized, HTTPErrorResp{
Error: "Unauthorized user",
@@ -1366,8 +1424,6 @@ func getUserAttempts(c *gin.Context) {
return
}
- isContestant := user.Role == core.USER_ROLES["contestant"]
-
userIDStr := c.Param("user_id")
userID, err := strconv.ParseUint(userIDStr, 10, 64)
if err != nil {
@@ -1379,9 +1435,11 @@ func getUserAttempts(c *gin.Context) {
submissionUser, err := database.QueryUserById(uint(userID))
if err != nil {
- c.JSON(http.StatusNotFound, HTTPErrorResp{
- Error: "User not found",
- })
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ c.JSON(http.StatusNotFound, HTTPErrorResp{Error: "User not found"})
+ } else {
+ c.JSON(http.StatusInternalServerError, HTTPErrorResp{Error: "DATABASE ERROR while processing the request."})
+ }
return
}
@@ -1402,36 +1460,47 @@ func getUserAttempts(c *gin.Context) {
}
resp := make([]SubmissionResp, 0, len(attempts))
+ challengeCache := make(map[uint]database.Challenge)
+ accessCache := make(map[uint]bool)
for _, attempt := range attempts {
- if isContestant && (!attempt.Correct || attempt.Cheating) {
- continue
+ challenge, exists := challengeCache[attempt.ChallengeID]
+ if !exists {
+ challenges, err := database.QueryChallengeEntries("id", strconv.FormatUint(uint64(attempt.ChallengeID), 10))
+ if err != nil {
+ log.Errorf("DATABASE ERROR while fetching challenge details: %s", err.Error())
+ continue
+ }
+ if len(challenges) == 0 {
+ continue
+ }
+ challenge = challenges[0]
+ challengeCache[attempt.ChallengeID] = challenge
}
-
- challenge, err := database.QueryChallengeEntries("id", strconv.Itoa(int(attempt.ChallengeID)))
- if err != nil {
- log.Errorf("DATABASE ERROR while fetching challenge details: %s", err.Error())
- continue
+ canViewSecrets, exists := accessCache[attempt.ChallengeID]
+ if !exists {
+ var err error
+ canViewSecrets, err = canViewChallengeSecrets(&user, &challenge)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, HTTPErrorResp{Error: "DATABASE ERROR while checking challenge access."})
+ return
+ }
+ accessCache[attempt.ChallengeID] = canViewSecrets
}
- if len(challenge) == 0 {
+ if !canViewSecrets && (!attempt.Correct || attempt.Cheating) {
continue
}
- challengeTags := make([]string, len(challenge[0].Tags))
- for index, tag := range challenge[0].Tags {
- challengeTags[index] = tag.TagName
- }
-
submissionResp := SubmissionResp{
UserId: submissionUser.ID,
Username: submissionUser.Username,
- ChallId: challenge[0].ID,
- ChallName: challenge[0].Name,
+ ChallId: challenge.ID,
+ ChallName: challenge.Name,
SolvedAt: attempt.SolvedAt,
Success: attempt.Correct,
}
- if !isContestant {
+ if canViewSecrets {
submissionResp.Flag = attempt.Flag
submissionResp.Cheating = attempt.Cheating
}
@@ -1442,7 +1511,15 @@ func getUserAttempts(c *gin.Context) {
c.JSON(http.StatusOK, resp)
}
+// @Summary Return leaderboard score history
+// @Tags info
+// @Produce json
+// @Security ApiKeyAuth
+// @Success 200 {array} database.UserLeaderboardResp
+// @Router /api/info/leaderboard-graph [get]
func getLeaderboardGraphHandler(c *gin.Context) {
+ leaderboardCacheMu.Lock()
+ defer leaderboardCacheMu.Unlock()
var topUsers []uint
// TODO: Add a check for leaderboard stale to prevent stale graphs
// Try if graphCache and leaderboardCache can be merged.
diff --git a/api/instance.go b/api/instance.go
index e2adc9ab..287d2d7c 100644
--- a/api/instance.go
+++ b/api/instance.go
@@ -59,6 +59,14 @@ func instanceToAdminResponse(instance *cache.Instance) AdminInstanceResponse {
}
}
+// @Summary Spawn a per-user challenge instance
+// @Tags instances
+// @Produce json
+// @Param challenge_name path string true "Challenge name"
+// @Security ApiKeyAuth
+// @Success 200 {object} api.InstanceResponse
+// @Failure 400 {object} api.HTTPErrorResp
+// @Router /api/instances/{challenge_name}/spawn [post]
func spawnInstanceHandler(ctx *gin.Context) {
challengeName := ctx.Param("challenge_name")
if challengeName == "" {
@@ -103,6 +111,14 @@ func spawnInstanceHandler(ctx *gin.Context) {
ctx.JSON(http.StatusOK, instanceToResponse(instance))
}
+// @Summary Get the current user's challenge instance
+// @Tags instances
+// @Produce json
+// @Param challenge_name path string true "Challenge name"
+// @Security ApiKeyAuth
+// @Success 200 {object} api.InstanceResponse
+// @Failure 404 {object} api.HTTPErrorResp
+// @Router /api/instances/{challenge_name} [get]
func getUserInstanceHandler(ctx *gin.Context) {
challengeName := ctx.Param("challenge_name")
if challengeName == "" {
@@ -141,6 +157,12 @@ func getUserInstanceHandler(ctx *gin.Context) {
ctx.JSON(http.StatusOK, instanceToResponse(instance))
}
+// @Summary List the current user's instances
+// @Tags instances
+// @Produce json
+// @Security ApiKeyAuth
+// @Success 200 {array} api.InstanceResponse
+// @Router /api/instances [get]
func getUserInstancesHandler(ctx *gin.Context) {
username, err := coreUtils.GetUser(ctx.GetHeader("Authorization"))
if err != nil {
@@ -180,6 +202,15 @@ func getUserInstancesHandler(ctx *gin.Context) {
ctx.JSON(http.StatusOK, response)
}
+// @Summary Extend the current user's challenge instance
+// @Tags instances
+// @Produce json
+// @Param challenge_name path string true "Challenge name"
+// @Param seconds formData int false "Requested extension in seconds"
+// @Security ApiKeyAuth
+// @Success 200 {object} api.InstanceResponse
+// @Failure 400 {object} api.HTTPErrorResp
+// @Router /api/instances/{challenge_name}/extend [post]
func extendInstanceHandler(ctx *gin.Context) {
challengeName := ctx.Param("challenge_name")
if challengeName == "" {
@@ -248,6 +279,13 @@ func extendInstanceHandler(ctx *gin.Context) {
ctx.JSON(http.StatusOK, instanceToResponse(instance))
}
+// @Summary Delete the current user's challenge instance
+// @Tags instances
+// @Produce json
+// @Param challenge_name path string true "Challenge name"
+// @Security ApiKeyAuth
+// @Success 200 {object} api.HTTPPlainResp
+// @Router /api/instances/{challenge_name} [delete]
func killUserInstanceHandler(ctx *gin.Context) {
challengeName := ctx.Param("challenge_name")
if challengeName == "" {
@@ -288,6 +326,12 @@ func killUserInstanceHandler(ctx *gin.Context) {
})
}
+// @Summary List all active instances
+// @Tags admin
+// @Produce json
+// @Security ApiKeyAuth
+// @Success 200 {array} api.AdminInstanceResponse
+// @Router /api/admin/instances [get]
func adminGetAllInstancesHandler(ctx *gin.Context) {
instances, err := manager.GetAllInstances()
if err != nil {
@@ -309,6 +353,13 @@ func adminGetAllInstancesHandler(ctx *gin.Context) {
ctx.JSON(http.StatusOK, response)
}
+// @Summary Get an instance by ID
+// @Tags admin
+// @Produce json
+// @Param instance_id path string true "Instance ID"
+// @Security ApiKeyAuth
+// @Success 200 {object} api.AdminInstanceResponse
+// @Router /api/admin/instances/{instance_id} [get]
func adminGetInstanceHandler(ctx *gin.Context) {
instanceID := ctx.Param("instance_id")
if instanceID == "" {
@@ -329,6 +380,13 @@ func adminGetInstanceHandler(ctx *gin.Context) {
ctx.JSON(http.StatusOK, instanceToAdminResponse(instance))
}
+// @Summary Delete an instance by ID
+// @Tags admin
+// @Produce json
+// @Param instance_id path string true "Instance ID"
+// @Security ApiKeyAuth
+// @Success 200 {object} api.HTTPPlainResp
+// @Router /api/admin/instances/{instance_id} [delete]
func adminKillInstanceHandler(ctx *gin.Context) {
instanceID := ctx.Param("instance_id")
if instanceID == "" {
@@ -351,6 +409,13 @@ func adminKillInstanceHandler(ctx *gin.Context) {
})
}
+// @Summary Delete all instances owned by a user
+// @Tags admin
+// @Produce json
+// @Param user_id path string true "User ID"
+// @Security ApiKeyAuth
+// @Success 200 {object} api.HTTPPlainResp
+// @Router /api/admin/instances/user/{user_id} [delete]
func adminKillUserInstancesHandler(ctx *gin.Context) {
userID := ctx.Param("user_id")
if userID == "" {
@@ -381,6 +446,13 @@ func adminKillUserInstancesHandler(ctx *gin.Context) {
})
}
+// @Summary Delete all instances for a challenge
+// @Tags admin
+// @Produce json
+// @Param challenge_name path string true "Challenge name"
+// @Security ApiKeyAuth
+// @Success 200 {object} api.HTTPPlainResp
+// @Router /api/admin/instances/challenge/{challenge_name} [delete]
func adminKillChallengeInstancesHandler(ctx *gin.Context) {
challengeName := ctx.Param("challenge_name")
if challengeName == "" {
diff --git a/api/main.go b/api/main.go
index 37c932bb..8b5ee614 100644
--- a/api/main.go
+++ b/api/main.go
@@ -1,7 +1,13 @@
package api
import (
+ "context"
+ "crypto/tls"
+ "errors"
+ "fmt"
"net/http"
+ "strconv"
+ "time"
"github.com/gin-gonic/gin"
"github.com/sdslabs/beastv4/core/cache"
@@ -23,67 +29,131 @@ import (
const (
DEFAULT_BEAST_PORT = ":5005"
+ shutdownTimeout = 30 * time.Second
)
var BeastScheduler scheduler.Scheduler = scheduler.NewScheduler()
func runBeastApiBootsteps(defaultauthorpassword string) error {
- manager.RunBeastBootsteps(defaultauthorpassword)
-
- return nil
+ return manager.RunBeastBootsteps(defaultauthorpassword)
}
// @title Beast API
-// @version 1.0
-// @description Beast the automatic deployment tool for playCTF
+// @version 0.2
+// @description Authenticated API for Beast CTF challenge deployment and competition services.
// @contact.name SDSLabs
// @contact.url https://chat.sdslabs.co
-// @contact.email contact.sdslabs.co.in
+// @contact.email contact@sdslabs.co.in
// @license.name Apache 2.0
-// @license.url http://www.apache.org/licenses/LICENSE-2.0.html
+// @license.url https://www.apache.org/licenses/LICENSE-2.0.html
-// @host playCTF.sdslabs.co
// @BasePath /
+// @schemes https
// @securityDefinitions.apikey ApiKeyAuth
// @in header
// @name Authorization
-func RunBeastApiServer(port, defaultauthorpassword string, autoDeploy, healthProbe, periodicSync bool, noCache bool) {
+func listenAddress(port string) (string, error) {
+ if port == "" {
+ return DEFAULT_BEAST_PORT, nil
+ }
+ value, err := strconv.Atoi(port)
+ if err != nil || value < 1 || value > 65535 {
+ return "", fmt.Errorf("invalid API port %q", port)
+ }
+ return ":" + strconv.Itoa(value), nil
+}
+
+func newHTTPServer(address string, handler http.Handler) *http.Server {
+ return &http.Server{
+ Addr: address,
+ Handler: handler,
+ ReadHeaderTimeout: 10 * time.Second,
+ ReadTimeout: 30 * time.Second,
+ IdleTimeout: 2 * time.Minute,
+ MaxHeaderBytes: 64 << 10,
+ TLSConfig: &tls.Config{
+ MinVersion: tls.VersionTLS12,
+ },
+ }
+}
+
+func RunBeastApiServer(ctx context.Context, port, defaultauthorpassword string, autoDeploy, healthProbe, periodicSync bool, noCache bool) error {
log.Info("Bootstrapping Beast API server")
- config.InitConfig()
+ if err := config.InitConfig(); err != nil {
+ return err
+ }
- if port != "" {
- port = ":" + port
- } else {
- port = DEFAULT_BEAST_PORT
+ address, err := listenAddress(port)
+ if err != nil {
+ return err
}
+ auth.Init(core.ITERATIONS, core.HASH_LENGTH, core.TIMEPERIOD, core.ISSUER, config.Cfg.JWTSecret, []string{core.USER_ROLES["author"], core.USER_ROLES["maintainer"]}, []string{core.USER_ROLES["admin"]}, []string{core.USER_ROLES["contestant"]})
+ if err := database.Init(); err != nil {
+ if database.Db != nil {
+ if sqlDB, dbErr := database.Db.DB(); dbErr == nil {
+ _ = sqlDB.Close()
+ }
+ }
+ return err
+ }
+ cache.Configure(config.Cfg.RedisConf.User, config.Cfg.RedisConf.Password, config.Cfg.RedisConf.Host, config.Cfg.RedisConf.Port, config.Cfg.RedisConf.Db, config.Cfg.RedisConf.TLS, config.Cfg.RedisConf.CAFile, config.Cfg.RedisConf.ServerName)
+ if err := cache.Init(); err != nil {
+ if sqlDB, dbErr := database.Db.DB(); dbErr == nil {
+ _ = sqlDB.Close()
+ }
+ return err
+ }
+ if err := remoteManager.Init(); err != nil {
+ _ = cache.Close()
+ if sqlDB, dbErr := database.Db.DB(); dbErr == nil {
+ _ = sqlDB.Close()
+ }
+ return err
+ }
manager.Q = wpool.InitQueue(core.MAX_QUEUE_SIZE, nil)
manager.Q.StartWorkers(&manager.Worker{})
-
- auth.Init(core.ITERATIONS, core.HASH_LENGTH, core.TIMEPERIOD, core.ISSUER, config.Cfg.JWTSecret, []string{core.USER_ROLES["author"]}, []string{core.USER_ROLES["admin"]}, []string{core.USER_ROLES["contestant"]})
- remoteManager.Init()
- database.Init()
- cache.Init()
- startDynamicScoreWorker()
- go manager.InstanceCleanupProber()
+ backgroundCtx, stopBackground := context.WithCancel(ctx)
+ dynamicScoreDone := startDynamicScoreWorker(backgroundCtx)
+ instanceCleanupDone := make(chan struct{})
+ go func() {
+ defer close(instanceCleanupDone)
+ manager.InstanceCleanupProber(backgroundCtx)
+ }()
+ healthCheckDone := make(chan struct{})
+ if healthProbe || config.Cfg.HealthProber {
+ go func() {
+ defer close(healthCheckDone)
+ manager.BeastHealthCheckProber(backgroundCtx, config.Cfg.TickerFrequency)
+ }()
+ } else {
+ close(healthCheckDone)
+ }
+ defer func() {
+ stopBackground()
+ <-dynamicScoreDone
+ <-instanceCleanupDone
+ <-healthCheckDone
+ }()
// Initialise and start the Hub
// Must be started before the Notification Router, since SSE handler has access to SSE Hub
sse.Init()
- runBeastApiBootsteps(defaultauthorpassword)
+ if err := runBeastApiBootsteps(defaultauthorpassword); err != nil {
+ return err
+ }
// Initialize Gin router.
router := initGinRouter()
// Setup gin middlewares
router.Use(gin.Logger())
- router.Use(gin.Recovery())
router.GET("/api/docs/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
router.GET("/", func(c *gin.Context) {
@@ -103,11 +173,9 @@ func RunBeastApiServer(port, defaultauthorpassword string, autoDeploy, healthPro
if periodicSync {
log.Infof("Scheduling periodic remote sync and auto update for beast with period: %v", config.Cfg.RemoteSyncPeriod)
- BeastScheduler.ScheduleEvery(config.Cfg.RemoteSyncPeriod, manager.AutoUpdate)
- }
-
- if healthProbe || config.Cfg.HealthProber {
- go manager.BeastHeathCheckProber(config.Cfg.TickerFrequency)
+ if err := BeastScheduler.ScheduleEvery(config.Cfg.RemoteSyncPeriod, manager.AutoUpdate); err != nil {
+ return fmt.Errorf("schedule periodic remote sync: %w", err)
+ }
}
if autoDeploy {
@@ -117,5 +185,28 @@ func RunBeastApiServer(port, defaultauthorpassword string, autoDeploy, healthPro
if noCache {
log.Infof("Starting Beast server in no cache mode")
}
- router.Run(port)
+ server := newHTTPServer(address, router)
+ serverErr := make(chan error, 1)
+ go func() {
+ serverErr <- server.ListenAndServeTLS(config.Cfg.ServerConfig.TLSCertFile, config.Cfg.ServerConfig.TLSKeyFile)
+ }()
+
+ select {
+ case err := <-serverErr:
+ if errors.Is(err, http.ErrServerClosed) {
+ return nil
+ }
+ return fmt.Errorf("serve Beast API: %w", err)
+ case <-ctx.Done():
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
+ defer cancel()
+ if err := server.Shutdown(shutdownCtx); err != nil {
+ return fmt.Errorf("shut down Beast API: %w", err)
+ }
+ err := <-serverErr
+ if err != nil && !errors.Is(err, http.ErrServerClosed) {
+ return fmt.Errorf("serve Beast API: %w", err)
+ }
+ return nil
+ }
}
diff --git a/api/main_test.go b/api/main_test.go
new file mode 100644
index 00000000..183ef498
--- /dev/null
+++ b/api/main_test.go
@@ -0,0 +1,35 @@
+package api
+
+import (
+ "crypto/tls"
+ "net/http"
+ "testing"
+ "time"
+)
+
+func TestListenAddressValidatesPort(t *testing.T) {
+ for _, port := range []string{"0", "65536", "not-a-port", "5005; shutdown"} {
+ if _, err := listenAddress(port); err == nil {
+ t.Fatalf("expected invalid port %q to fail", port)
+ }
+ }
+ if address, err := listenAddress("5005"); err != nil || address != ":5005" {
+ t.Fatalf("listenAddress returned %q, %v", address, err)
+ }
+}
+
+func TestHTTPServerHasDefensiveTimeouts(t *testing.T) {
+ server := newHTTPServer(":5005", http.NewServeMux())
+ if server.ReadHeaderTimeout <= 0 || server.ReadTimeout <= 0 || server.IdleTimeout <= 0 {
+ t.Fatalf("missing HTTP timeouts: %+v", server)
+ }
+ if server.MaxHeaderBytes <= 0 || server.MaxHeaderBytes > 64<<10 {
+ t.Fatalf("unexpected max header bytes: %d", server.MaxHeaderBytes)
+ }
+ if server.ReadHeaderTimeout > 30*time.Second {
+ t.Fatalf("read header timeout is too permissive: %v", server.ReadHeaderTimeout)
+ }
+ if server.TLSConfig == nil || server.TLSConfig.MinVersion < tls.VersionTLS12 {
+ t.Fatalf("missing minimum TLS version: %+v", server.TLSConfig)
+ }
+}
diff --git a/api/manage.go b/api/manage.go
index e40505ce..14c483a1 100644
--- a/api/manage.go
+++ b/api/manage.go
@@ -1,14 +1,17 @@
package api
import (
+ "errors"
"fmt"
+ "io"
+ "mime/multipart"
"net/http"
"os"
"path/filepath"
"strings"
+ "sync"
"time"
- "github.com/BurntSushi/toml"
"github.com/gin-gonic/gin"
"github.com/sdslabs/beastv4/core"
cfg "github.com/sdslabs/beastv4/core/config"
@@ -20,6 +23,13 @@ import (
log "github.com/sirupsen/logrus"
)
+const (
+ maxChallengeUploadBytes int64 = 256 << 20
+ maxChallengeUploadRequestBytes = maxChallengeUploadBytes + 1<<20
+)
+
+var challengeUploadMu sync.Mutex
+
// Handles route related to manage all the challenges or the challenges related to a particular tag for current beast remote.
// @Summary Handles challenge management actions for multiple challenges.
// @Description Handles challenge management routes for multiple the challenges with actions which includes - DEPLOY, UNDEPLOY.
@@ -30,7 +40,7 @@ import (
// @Param tag query string false "Tag for a group of challenges"
// @Success 200 {object} api.HTTPPlainResp
// @Failure 400 {object} api.HTTPPlainResp
-// @Router /api/manage/multiple/:action [post]
+// @Router /api/manage/multiple/{action} [post]
func manageMultipleChallengeHandlerTagBased(c *gin.Context) {
// If no tags are provided we by default we apply the action to all
// the challenges.
@@ -41,7 +51,7 @@ func manageMultipleChallengeHandlerTagBased(c *gin.Context) {
// Since upto this point the request is already authorized, we use a default
// username if any error occurs while getting the username.
username, err := coreUtils.GetUser(c.GetHeader("Authorization"))
- if err == nil {
+ if err != nil {
log.Warnf("Error while getting user from authorization header, using default user(since already authorized)")
username = core.DEFAULT_USER_NAME
}
@@ -95,6 +105,9 @@ func manageChallengeHandler(c *gin.Context) {
identifier := c.PostForm("name")
action := c.PostForm("action")
authorization := c.GetHeader("Authorization")
+ if !authorizeChallengeManagement(c, identifier, action == core.MANAGE_ACTION_DEPLOY) {
+ return
+ }
log.Infof("Trying %s for challenge with identifier : %s", action, identifier)
if msgs := manager.LogTransaction(identifier, action, authorization); msgs != nil {
@@ -121,9 +134,7 @@ func manageChallengeHandler(c *gin.Context) {
}
if action == core.MANAGE_ACTION_PURGE {
- leaderboardStale = true
- graphCacheStale = true
- adminLeaderboardStale = true
+ markLeaderboardCachesStale()
}
respStr := fmt.Sprintf("Your action %s on challenge %s has been triggered, check stats.", action, identifier)
@@ -184,9 +195,7 @@ func manageMultipleChallengeHandlerNameBased(c *gin.Context) {
}
if action == core.MANAGE_ACTION_PURGE {
- leaderboardStale = true
- graphCacheStale = true
- adminLeaderboardStale = true
+ markLeaderboardCachesStale()
}
c.JSON(http.StatusOK, HTTPPlainMapResp{
@@ -204,7 +213,7 @@ func manageMultipleChallengeHandlerNameBased(c *gin.Context) {
// @Success 200 {object} api.HTTPPlainResp
// @Failure 400 {object} api.HTTPPlainResp
// @Failure 406 {object} api.HTTPPlainResp
-// @Router /api/manage/deploy/local [post]
+// @Router /api/manage/deploy/local/ [post]
func deployLocalChallengeHandler(c *gin.Context) {
action := core.MANAGE_ACTION_DEPLOY
challDir := c.PostForm("challenge_dir")
@@ -216,9 +225,26 @@ func deployLocalChallengeHandler(c *gin.Context) {
})
return
}
+ if err := manager.ValidateChallengeConfig(challDir); err != nil {
+ c.JSON(http.StatusBadRequest, HTTPErrorResp{Error: err.Error()})
+ return
+ }
+ configuration, err := cfg.LoadChallengeConfig(filepath.Join(challDir, core.CHALLENGE_CONFIG_FILE_NAME))
+ if err != nil {
+ c.JSON(http.StatusBadRequest, HTTPErrorResp{Error: "challenge configuration is invalid"})
+ return
+ }
+ user, ok := authenticatedManager(c)
+ if !ok {
+ return
+ }
+ if !userOwnsChallengeConfig(user, configuration) {
+ c.JSON(http.StatusForbidden, HTTPErrorResp{Error: "challenge management access denied"})
+ return
+ }
log.Info("In local deploy challenge Handler")
- err := manager.DeployChallengePipeline(challDir)
+ err = manager.DeployChallengePipeline(challDir)
if msgs := manager.LogTransaction(strings.Split(challDir, "/")[len(strings.Split(challDir, "/"))-1], action, authorization); msgs != nil {
log.Warn("Error while saving transaction")
}
@@ -247,7 +273,7 @@ func deployLocalChallengeHandler(c *gin.Context) {
// @Param action query string true "Action to apply on the beast static content provider"
// @Success 200 {object} api.HTTPPlainResp
// @Failure 400 {object} api.HTTPPlainResp
-// @Router /api/manage/static/:action [post]
+// @Router /api/manage/static/{action} [post]
func beastStaticContentHandler(c *gin.Context) {
action := c.Param("action")
identifier := core.BEAST_STATIC_CONTAINER_NAME
@@ -261,14 +287,20 @@ func beastStaticContentHandler(c *gin.Context) {
// Deploy and Undeploy
switch action {
case core.MANAGE_ACTION_DEPLOY:
- go manager.DeployStaticContentContainer()
+ if err := manager.DeployStaticContentContainer(); err != nil {
+ c.JSON(http.StatusBadRequest, HTTPPlainResp{Message: err.Error()})
+ return
+ }
c.JSON(http.StatusOK, HTTPPlainResp{
- Message: "Static container deploy started",
+ Message: "Static container deployed",
})
return
case core.MANAGE_ACTION_UNDEPLOY:
- go manager.UndeployStaticContentContainer()
+ if err := manager.UndeployStaticContentContainer(); err != nil {
+ c.JSON(http.StatusBadRequest, HTTPPlainResp{Message: err.Error()})
+ return
+ }
c.JSON(http.StatusOK, HTTPPlainResp{
Message: "Static content container undeploy started",
})
@@ -290,9 +322,12 @@ func beastStaticContentHandler(c *gin.Context) {
// @Param challenge query string true "Name of the challenge to commit"
// @Success 200 {object} api.HTTPPlainResp
// @Failure 500 {object} api.HTTPPlainResp
-// @Router /api/manage/commit/ [post]
+// @Router /api/manage/challenge/verify [post]
func commitChallenge(c *gin.Context) {
challenge := c.PostForm("challenge")
+ if !authorizeChallengeManagement(c, challenge, false) {
+ return
+ }
err := manager.CommitChallengeContainer(challenge)
@@ -319,6 +354,9 @@ func commitChallenge(c *gin.Context) {
// @Router /api/manage/commit/ [post]
func verifyHandler(c *gin.Context) {
challengeName := c.PostForm("challenge")
+ if !authorizeChallengeManagement(c, challengeName, true) {
+ return
+ }
challengeRemoteDir := coreUtils.GetChallengeDir(challengeName)
if challengeRemoteDir == "" {
log.Errorf("Challenge does not exist")
@@ -353,7 +391,7 @@ func verifyHandler(c *gin.Context) {
// @Param after query string false "Time after which the action on the selector should be executed should be of duration format as in '1m20s' etc."
// @Success 200 {object} api.HTTPPlainResp
// @Failure 400 {object} api.HTTPPlainResp
-// @Router /api/manage/schedule/:action [post]
+// @Router /api/manage/schedule/{action} [post]
func manageScheduledAction(c *gin.Context) {
action := c.Param("action")
challenge := c.PostForm("challenge")
@@ -361,7 +399,7 @@ func manageScheduledAction(c *gin.Context) {
authorization := c.GetHeader("Authorization")
username, err := coreUtils.GetUser(authorization)
- if err == nil {
+ if err != nil {
log.Warn("Error while getting user from authorization header, using default user(since already authorized)")
username = core.DEFAULT_USER_NAME
}
@@ -414,12 +452,18 @@ func manageScheduledAction(c *gin.Context) {
if tag != "" {
manager.LogTransaction(fmt.Sprintf("TAG:%s", tag), "SCHEDULE::"+action, authorization)
- BeastScheduler.ScheduleAfter(duration, manager.HandleTagRelatedChallenges, action, tag, username)
+ if err := BeastScheduler.ScheduleAfter(duration, manager.HandleTagRelatedChallenges, action, tag, username); err != nil {
+ c.JSON(http.StatusInternalServerError, HTTPErrorResp{Error: "failed to schedule challenge action"})
+ return
+ }
log.Infof("Scheduled %s for challenges with tag %s", action, tag)
} else {
manager.LogTransaction(challenge, "SCHEDULE::"+action, authorization)
- BeastScheduler.ScheduleAfter(duration, actionHandler, challenge)
+ if err := BeastScheduler.ScheduleAfter(duration, actionHandler, challenge); err != nil {
+ c.JSON(http.StatusInternalServerError, HTTPErrorResp{Error: "failed to schedule challenge action"})
+ return
+ }
log.Infof("Scheduled %s for challenge %s", action, challenge)
}
@@ -441,40 +485,46 @@ func manageScheduledAction(c *gin.Context) {
// @Failure 500 {object} api.HTTPErrorResp
// @Router /api/manage/challenge/upload [post]
func manageUploadHandler(c *gin.Context) {
+ c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxChallengeUploadRequestBytes)
file, err := c.FormFile("file")
- // The file cannot be received.
if err != nil {
- c.AbortWithStatusJSON(http.StatusBadRequest, HTTPPlainResp{
- Message: "no file received from user",
+ status := http.StatusBadRequest
+ var maxBytesErr *http.MaxBytesError
+ if errors.As(err, &maxBytesErr) {
+ status = http.StatusRequestEntityTooLarge
+ }
+ c.AbortWithStatusJSON(status, HTTPPlainResp{
+ Message: "a ZIP challenge archive is required",
})
return
}
-
- if err = utils.CreateIfNotExistDir(core.BEAST_TEMP_DIR); err != nil {
- if err := os.MkdirAll(core.BEAST_TEMP_DIR, 0755); err != nil {
- c.JSON(http.StatusInternalServerError, HTTPErrorResp{
- Error: fmt.Sprintf("Could not create dir %s: %s", core.BEAST_TEMP_DIR, err),
- })
- }
+ archiveName, err := challengeArchiveFilename(file.Filename)
+ if err != nil {
+ c.AbortWithStatusJSON(http.StatusBadRequest, HTTPErrorResp{Error: err.Error()})
+ return
}
- zipContextPath := filepath.Join(core.BEAST_TEMP_DIR, file.Filename)
-
- // The file is received, save it
- if err := c.SaveUploadedFile(file, zipContextPath); err != nil {
+ tempRoot, err := os.MkdirTemp("", "beast-upload-")
+ if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, HTTPErrorResp{
- Error: fmt.Sprintf("Unable to save file: %s", err),
+ Error: fmt.Sprintf("Unable to create upload staging directory: %s", err),
})
return
}
+ defer os.RemoveAll(tempRoot)
- // Extract and show from zip and return response
- tempStageDir, err := manager.UnzipChallengeFolder(zipContextPath, core.BEAST_TEMP_DIR)
-
- // log.Debug("The dir is ",tempStageDir)
+ zipContextPath := filepath.Join(tempRoot, archiveName)
+ if err := saveChallengeArchive(file, zipContextPath); err != nil {
+ status := http.StatusBadRequest
+ if errors.Is(err, errChallengeUploadTooLarge) {
+ status = http.StatusRequestEntityTooLarge
+ }
+ c.AbortWithStatusJSON(status, HTTPErrorResp{Error: err.Error()})
+ return
+ }
- // The file cannot be successfully un-zipped or the resultant was a malformed directory
+ tempStageDir, err := manager.UnzipChallengeFolder(zipContextPath, tempRoot)
if err != nil {
c.JSON(http.StatusBadRequest, HTTPErrorResp{
Error: fmt.Sprintf("The unzip process failed or the ZIP was unacceptable: %s", err),
@@ -482,35 +532,37 @@ func manageUploadHandler(c *gin.Context) {
return
}
- err = manager.ValidateChallengeConfig(tempStageDir)
- if err != nil {
- c.JSON(http.StatusOK, HTTPErrorResp{
+ if err := manager.ValidateChallengeConfig(tempStageDir); err != nil {
+ c.JSON(http.StatusBadRequest, HTTPErrorResp{
Error: err.Error(),
})
- }
-
- challengeUploadDirectory := filepath.Join(
- core.BEAST_GLOBAL_DIR,
- core.BEAST_UPLOADS_DIR,
- strings.TrimSuffix(file.Filename, filepath.Ext(file.Filename)),
- )
-
- if err = manager.CopyDir(tempStageDir, challengeUploadDirectory); err != nil {
- c.AbortWithStatusJSON(http.StatusInternalServerError, HTTPErrorResp{
- Error: fmt.Sprintf("Unable to move challenge directory: %s", err),
- })
return
}
- challengeName := filepath.Base(challengeUploadDirectory)
- configFile := filepath.Join(challengeUploadDirectory, core.CHALLENGE_CONFIG_FILE_NAME)
-
- var config cfg.BeastChallengeConfig
- _, err = toml.DecodeFile(configFile, &config)
+ configFile := filepath.Join(tempStageDir, core.CHALLENGE_CONFIG_FILE_NAME)
+ config, err := cfg.LoadChallengeConfig(configFile)
if err != nil {
- log.Errorf("Error while loading beast config for challenge %s : %s", challengeName, err)
+ log.Errorf("Error while loading uploaded challenge config: %s", err)
c.JSON(http.StatusBadRequest, HTTPErrorResp{
- Error: fmt.Sprintf("CONFIG ERROR: %s : %s", challengeName, err),
+ Error: fmt.Sprintf("CONFIG ERROR: %s", err),
+ })
+ return
+ }
+ user, ok := authenticatedManager(c)
+ if !ok {
+ return
+ }
+ if !userOwnsChallengeConfig(user, config) {
+ c.AbortWithStatusJSON(http.StatusForbidden, HTTPErrorResp{Error: "challenge management access denied"})
+ return
+ }
+ if err := persistUploadedChallenge(tempStageDir, config.Challenge.Metadata.Name); err != nil {
+ status := http.StatusInternalServerError
+ if errors.Is(err, os.ErrExist) {
+ status = http.StatusConflict
+ }
+ c.AbortWithStatusJSON(status, HTTPErrorResp{
+ Error: fmt.Sprintf("Unable to store challenge: %s", err),
})
return
}
@@ -528,10 +580,98 @@ func manageUploadHandler(c *gin.Context) {
})
}
+var errChallengeUploadTooLarge = errors.New("challenge archive exceeds the 256 MiB limit")
+
+func challengeArchiveFilename(name string) (string, error) {
+ if name == "" || name != filepath.Base(name) || strings.Contains(name, `\`) {
+ return "", fmt.Errorf("invalid challenge archive filename")
+ }
+ if !strings.EqualFold(filepath.Ext(name), ".zip") || strings.TrimSuffix(name, filepath.Ext(name)) == "" {
+ return "", fmt.Errorf("challenge archive must have a non-empty .zip filename")
+ }
+ return name, nil
+}
+
+func saveChallengeArchive(header *multipart.FileHeader, destination string) error {
+ if header.Size < 0 || header.Size > maxChallengeUploadBytes {
+ return errChallengeUploadTooLarge
+ }
+ source, err := header.Open()
+ if err != nil {
+ return err
+ }
+ defer source.Close()
+
+ target, err := os.OpenFile(destination, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
+ if err != nil {
+ return err
+ }
+ written, copyErr := io.Copy(target, io.LimitReader(source, maxChallengeUploadBytes+1))
+ closeErr := target.Close()
+ if copyErr != nil {
+ return copyErr
+ }
+ if closeErr != nil {
+ return closeErr
+ }
+ if written > maxChallengeUploadBytes {
+ return errChallengeUploadTooLarge
+ }
+ return nil
+}
+
+func persistUploadedChallenge(source, challengeName string) error {
+ uploadsRoot := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_UPLOADS_DIR)
+ if err := os.MkdirAll(uploadsRoot, 0700); err != nil {
+ return err
+ }
+ rootInfo, err := os.Lstat(uploadsRoot)
+ if err != nil {
+ return err
+ }
+ if !rootInfo.IsDir() || rootInfo.Mode()&os.ModeSymlink != 0 {
+ return fmt.Errorf("uploads root is not a regular directory")
+ }
+ if err := os.Chmod(uploadsRoot, 0700); err != nil {
+ return err
+ }
+
+ stageRoot, err := os.MkdirTemp(uploadsRoot, ".upload-")
+ if err != nil {
+ return err
+ }
+ defer os.RemoveAll(stageRoot)
+ stagedChallenge := filepath.Join(stageRoot, challengeName)
+ if err := manager.CopyDir(source, stagedChallenge); err != nil {
+ return err
+ }
+
+ challengeUploadMu.Lock()
+ defer challengeUploadMu.Unlock()
+ destination := filepath.Join(uploadsRoot, challengeName)
+ if _, err := os.Lstat(destination); err == nil {
+ return fmt.Errorf("challenge %q is already uploaded: %w", challengeName, os.ErrExist)
+ } else if !os.IsNotExist(err) {
+ return err
+ }
+ return os.Rename(stagedChallenge, destination)
+}
+
+// @Summary Validate a configured challenge flag as its manager
+// @Tags manage
+// @Produce json
+// @Param challenge_name formData string true "Challenge name"
+// @Param flag formData string true "Flag to validate"
+// @Security ApiKeyAuth
+// @Success 200 {object} api.HTTPPlainResp
+// @Failure 403 {object} api.HTTPErrorResp
+// @Router /api/manage/challenge/validateflag [post]
func validateFlagHandler(c *gin.Context) {
flag := c.PostForm("flag")
challenge_name := c.PostForm("challenge_name")
- authorization := c.GetHeader("Authorization")
+ if !authorizeChallengeManagement(c, challenge_name, false) {
+ return
+ }
challenges, err := database.QueryChallengeEntries("name", challenge_name)
if err != nil {
@@ -548,7 +688,7 @@ func validateFlagHandler(c *gin.Context) {
return
}
- if msgs := manager.LogTransaction(challenge_name, "VALIDATE_FLAG: "+flag, authorization); msgs != nil {
+ if msgs := manager.LogTransaction(challenge_name, "VALIDATE_FLAG", c.GetHeader("Authorization")); msgs != nil {
log.Warn("Error while saving transaction")
}
diff --git a/api/notification.go b/api/notification.go
index a550ca16..cba1cd8d 100644
--- a/api/notification.go
+++ b/api/notification.go
@@ -64,7 +64,7 @@ func addNotification(c *gin.Context) {
// @Success 200 {object} api.HTTPPlainResp
// @Failure 400 {object} api.HTTPErrorResp
// @Failure 500 {object} api.HTTPErrorResp
-// @Router /api/notification/delete [post]
+// @Router /api/notification/delete [delete]
func removeNotification(c *gin.Context) {
id := c.PostForm("id")
@@ -117,7 +117,7 @@ func removeNotification(c *gin.Context) {
// @Success 200 {object} api.HTTPPlainResp
// @Failure 400 {object} api.HTTPErrorResp
// @Failure 500 {object} api.HTTPErrorResp
-// @Router /api/notification/update [post]
+// @Router /api/notification/update [put]
func updateNotifications(c *gin.Context) {
id := c.PostForm("id")
changedtitle := c.PostForm("title")
@@ -170,7 +170,7 @@ func updateNotifications(c *gin.Context) {
// @Produce json
// @Success 200 {object} api.HTTPPlainResp
// @Failure 500 {object} api.HTTPErrorResp
-// @Router /api/notification/available [post]
+// @Router /api/notification/available [get]
func availableNotificationHandler(c *gin.Context) {
notifications, err := database.QueryAllNotification()
if err != nil {
@@ -204,6 +204,12 @@ func availableNotificationHandler(c *gin.Context) {
return
}
+// @Summary Stream server-sent notifications
+// @Tags notification
+// @Produce text/event-stream
+// @Security ApiKeyAuth
+// @Success 200 {string} string "SSE stream"
+// @Router /api/notification/stream [get]
func streamNotification(c *gin.Context) {
c.Writer.Header().Set("Content-Type", "text/event-stream")
c.Writer.Header().Set("Cache-Control", "no-cache")
diff --git a/api/otp.go b/api/otp.go
index cd1fc7ac..7821af2b 100644
--- a/api/otp.go
+++ b/api/otp.go
@@ -2,20 +2,24 @@ package api
import (
"bytes"
+ "crypto/hmac"
+ "crypto/rand"
+ "crypto/sha256"
"crypto/tls"
"errors"
"fmt"
"html/template"
"log"
- "math/rand"
+ "math/big"
+ "net"
"net/http"
+ "net/mail"
"net/smtp"
"os"
"path/filepath"
"strings"
"time"
- jwt "github.com/dgrijalva/jwt-go"
"github.com/gin-gonic/gin"
"github.com/sdslabs/beastv4/core"
"github.com/sdslabs/beastv4/core/config"
@@ -24,9 +28,18 @@ import (
"gorm.io/gorm"
)
-func generateOTP() string {
- r := rand.New(rand.NewSource(time.Now().UnixNano()))
- return fmt.Sprintf("%06d", r.Intn(1000000)) // 6-digit OTP
+const (
+ otpPurposeRegistration = "registration"
+ otpPurposePasswordReset = "password_reset"
+ otpLifetime = 5 * time.Minute
+)
+
+func generateOTP() (string, error) {
+ value, err := rand.Int(rand.Reader, big.NewInt(1000000))
+ if err != nil {
+ return "", err
+ }
+ return fmt.Sprintf("%06d", value.Int64()), nil
}
// sendEmail sends an OTP email using an SMTP client with TLS. Falls back to plain text if template is missing.
@@ -35,6 +48,14 @@ func sendEmail(email, otp string) error {
password := config.Cfg.MailConfig.Password
smtpHost := config.Cfg.MailConfig.SMTPHost
smtpPort := config.Cfg.MailConfig.SMTPPort
+ fromAddress, err := canonicalMailbox(from)
+ if err != nil {
+ return fmt.Errorf("invalid SMTP sender: %w", err)
+ }
+ recipientAddress, err := canonicalMailbox(email)
+ if err != nil {
+ return fmt.Errorf("invalid OTP recipient: %w", err)
+ }
// Email subject
subject := "Your OTP Code"
@@ -49,7 +70,8 @@ func sendEmail(email, otp string) error {
// Check if template file exists
var body bytes.Buffer
- _, err := os.Stat(emailTemplatePath)
+ htmlBody := false
+ _, err = os.Stat(emailTemplatePath)
if err == nil {
// Template exists, parse and execute
tmpl, err := template.ParseFiles(emailTemplatePath)
@@ -66,6 +88,7 @@ func sendEmail(email, otp string) error {
log.Println("Failed to execute email template:", err)
return err
}
+ htmlBody = true
} else {
// Template does not exist, send plain text email
log.Println("Template not found, sending plain text email.")
@@ -73,13 +96,13 @@ func sendEmail(email, otp string) error {
}
// Create email headers
- message := fmt.Sprintf("From: %s\r\n", from) +
- fmt.Sprintf("To: %s\r\n", email) +
+ message := fmt.Sprintf("From: %s\r\n", fromAddress) +
+ fmt.Sprintf("To: %s\r\n", recipientAddress) +
fmt.Sprintf("Subject: %s\r\n", subject) +
"MIME-Version: 1.0\r\n"
// Set Content-Type based on template availability
- if body.String()[0] == '<' {
+ if htmlBody {
message += "Content-Type: text/html; charset=\"utf-8\"\r\n\r\n"
} else {
message += "Content-Type: text/plain; charset=\"utf-8\"\r\n\r\n"
@@ -89,38 +112,48 @@ func sendEmail(email, otp string) error {
// Setup TLS connection
tlsConfig := &tls.Config{
- InsecureSkipVerify: true, // Set true only if SMTP server uses self-signed certs
- ServerName: smtpHost,
+ MinVersion: tls.VersionTLS12,
+ ServerName: smtpHost,
}
- // Connect to SMTP server
- conn, err := tls.Dial("tcp", smtpHost+":"+smtpPort, tlsConfig)
+ dialer := &net.Dialer{Timeout: 10 * time.Second}
+ rawConn, err := dialer.Dial("tcp", net.JoinHostPort(smtpHost, smtpPort))
if err != nil {
log.Println("Failed to connect to SMTP server:", err)
return err
}
+ conn := tls.Client(rawConn, tlsConfig)
+ if err := conn.SetDeadline(time.Now().Add(30 * time.Second)); err != nil {
+ _ = conn.Close()
+ return err
+ }
+ if err := conn.Handshake(); err != nil {
+ _ = conn.Close()
+ return fmt.Errorf("verify SMTP TLS connection: %w", err)
+ }
client, err := smtp.NewClient(conn, smtpHost)
if err != nil {
+ _ = conn.Close()
log.Println("Failed to create SMTP client:", err)
return err
}
defer client.Close()
// Authenticate
- auth := smtp.PlainAuth("", from, password, smtpHost)
+ auth := smtp.PlainAuth("", fromAddress, password, smtpHost)
if err := client.Auth(auth); err != nil {
log.Println("SMTP authentication failed:", err)
return err
}
// Set sender and recipient
- if err := client.Mail(from); err != nil {
+ if err := client.Mail(fromAddress); err != nil {
log.Println("Failed to set sender:", err)
return err
}
- if err := client.Rcpt(email); err != nil {
+ if err := client.Rcpt(recipientAddress); err != nil {
log.Println("Failed to set recipient:", err)
return err
}
@@ -150,268 +183,170 @@ func sendEmail(email, otp string) error {
return err
}
- fmt.Println("OTP email sent successfully to", email)
return nil
}
-func sendOTPHandler(c *gin.Context) {
- if config.SkipAuthorization {
- return
+func canonicalMailbox(value string) (string, error) {
+ if strings.ContainsAny(value, "\r\n") {
+ return "", errors.New("mailbox contains a line break")
}
- email := c.PostForm("email")
- email = strings.TrimSpace(strings.ToLower(email))
-
- smtpHost := config.Cfg.MailConfig.SMTPHost
- smtpPort := config.Cfg.MailConfig.SMTPPort
-
- if smtpHost == "" || smtpPort == "" {
- log.Printf("WARNING: %s", "SMTP not configured")
- c.JSON(http.StatusInternalServerError, HTTPErrorResp{
- Error: "SMTP not configured",
- })
- return
+ parsed, err := mail.ParseAddress(value)
+ if err != nil || parsed.Address != value {
+ return "", fmt.Errorf("mailbox must be a bare email address")
}
+ return parsed.Address, nil
+}
- otp := generateOTP()
- expiry := time.Now().Add(5 * time.Minute) // OTP expires in 5 minutes
-
- otpEntry, err := database.QueryOTPEntry(email)
+func otpCodeHash(email, purpose, code string) []byte {
+ mac := hmac.New(sha256.New, []byte(config.Cfg.JWTSecret))
+ _, _ = mac.Write([]byte(purpose + "\x00" + email + "\x00" + code))
+ return mac.Sum(nil)
+}
+func requestedOTPEmail(c *gin.Context) (string, error) {
+ email := strings.TrimSpace(strings.ToLower(c.PostForm("email")))
+ canonical, err := canonicalMailbox(email)
if err != nil {
- if errors.Is(err, gorm.ErrRecordNotFound) {
- otpEntry = database.OTP{
- Email: email,
- Code: otp,
- Expiry: expiry,
- }
- } else {
- log.Println("Failed to query OTP:", err)
- c.JSON(http.StatusInternalServerError, HTTPErrorResp{
- Error: "Failed to send OTP",
- })
- return
- }
+ return "", err
}
+ return canonical, nil
+}
- if otpEntry.Verified {
- c.JSON(http.StatusOK, HTTPPlainResp{
- Message: "Email already verified",
- })
+func issueOTP(c *gin.Context, purpose string, existingUserRequired bool) {
+ if !enforceOTPSendRateLimit(c) {
return
}
-
- otpEntry.Code = otp
- otpEntry.Expiry = expiry
-
- err = database.CreateOTPEntry(&otpEntry)
+ email, err := requestedOTPEmail(c)
if err != nil {
- log.Println("Failed to store OTP:", err)
- c.JSON(http.StatusInternalServerError, HTTPErrorResp{
- Error: "Failed to store OTP",
- })
+ c.JSON(http.StatusBadRequest, HTTPErrorResp{Error: "A valid email address is required"})
return
}
-
- // Send OTP to email
- err = sendEmail(email, otp)
+ mailConfig := config.Cfg.MailConfig
+ if mailConfig.From == "" || mailConfig.Password == "" || mailConfig.SMTPHost == "" || mailConfig.SMTPPort == "" {
+ c.JSON(http.StatusServiceUnavailable, HTTPErrorResp{Error: "SMTP not configured"})
+ return
+ }
+ eligible := true
+ _, userErr := database.QueryFirstUserEntry("email", email)
+ if userErr != nil && !errors.Is(userErr, gorm.ErrRecordNotFound) {
+ c.JSON(http.StatusInternalServerError, HTTPErrorResp{Error: "Failed to send OTP"})
+ return
+ }
+ if existingUserRequired {
+ eligible = userErr == nil
+ } else if userErr == nil {
+ c.JSON(http.StatusOK, HTTPPlainResp{Message: "If the request is eligible, an OTP has been sent"})
+ return
+ }
+ code, err := generateOTP()
if err != nil {
- log.Println("Failed to send OTP:", err)
- c.JSON(http.StatusInternalServerError, HTTPErrorResp{
- Error: "Failed to send OTP",
- })
+ c.JSON(http.StatusInternalServerError, HTTPErrorResp{Error: "Failed to generate OTP"})
return
}
-
- c.JSON(http.StatusOK, HTTPPlainResp{
- Message: "OTP sent successfully",
- })
-}
-
-func verifyOTPHandler(c *gin.Context) {
- email := c.PostForm("email")
- otp := strings.TrimSpace(c.PostForm("otp"))
- email = strings.TrimSpace(strings.ToLower(email))
-
- if !config.SkipAuthorization {
- smtpHost := config.Cfg.MailConfig.SMTPHost
- smtpPort := config.Cfg.MailConfig.SMTPPort
-
- if smtpHost == "" || smtpPort == "" {
- log.Printf("WARNING: %s", "SMTP not configured")
- c.JSON(http.StatusInternalServerError, HTTPErrorResp{
- Error: "SMTP not configured",
- })
+ now := time.Now()
+ if err := database.IssueOTP(email, purpose, otpCodeHash(email, purpose, code), now, now.Add(otpLifetime)); err != nil {
+ if errors.Is(err, database.ErrOTPRateLimited) {
+ c.Header("Retry-After", "60")
+ c.JSON(http.StatusTooManyRequests, HTTPErrorResp{Error: "OTP requested too recently"})
return
}
-
- otpEntry, err := database.QueryOTPEntry(email)
-
- if err != nil {
- if errors.Is(err, gorm.ErrRecordNotFound) {
- c.JSON(http.StatusUnauthorized, HTTPErrorResp{
- Error: "OTP not found",
- })
- } else {
- log.Println("Failed to query OTP:", err)
- c.JSON(http.StatusInternalServerError, HTTPErrorResp{
- Error: "Failed to send OTP",
- })
- return
- }
- }
-
- if otpEntry.Verified {
- c.JSON(http.StatusOK, HTTPPlainResp{
- Message: "Email already verified",
- })
- return
- }
-
- if err != nil {
- c.JSON(http.StatusInternalServerError, HTTPErrorResp{
- Error: "Failed to verify OTP",
- })
- return
- }
-
- if otpEntry.Code != otp {
- c.JSON(http.StatusUnauthorized, HTTPErrorResp{
- Error: "Invalid OTP",
- })
- return
- }
-
- if time.Now().After(otpEntry.Expiry) {
- c.JSON(http.StatusUnauthorized, HTTPErrorResp{
- Error: "OTP expired",
- })
- return
- }
- }
- err := database.VerifyOTPEntry(email)
-
- if err != nil {
- c.JSON(http.StatusInternalServerError, HTTPErrorResp{
- Error: "Failed to verify OTP",
- })
+ log.Println("Failed to store OTP:", err)
+ c.JSON(http.StatusInternalServerError, HTTPErrorResp{Error: "Failed to store OTP"})
return
}
-
- c.JSON(http.StatusOK, HTTPPlainResp{
- Message: "OTP verified successfully",
- })
-}
-
-func sendOTPForForgetHandler(c *gin.Context) {
- if config.SkipAuthorization {
+ if !eligible {
+ c.JSON(http.StatusOK, HTTPPlainResp{Message: "If the request is eligible, an OTP has been sent"})
return
}
- email := c.PostForm("email")
- email = strings.TrimSpace(strings.ToLower(email))
-
- smtpHost := config.Cfg.MailConfig.SMTPHost
- smtpPort := config.Cfg.MailConfig.SMTPPort
-
- if smtpHost == "" || smtpPort == "" {
- log.Printf("WARNING: %s", "SMTP not configured")
- c.JSON(http.StatusInternalServerError, HTTPErrorResp{
- Error: "SMTP not configured",
- })
+ if err := sendEmail(email, code); err != nil {
+ _ = database.DeleteOTPEntry(email)
+ log.Println("Failed to send OTP:", err)
+ c.JSON(http.StatusBadGateway, HTTPErrorResp{Error: "Failed to send OTP"})
return
}
+ c.JSON(http.StatusOK, HTTPPlainResp{Message: "If the request is eligible, an OTP has been sent"})
+}
- otp := generateOTP()
- expiry := time.Now().Add(5 * time.Minute) // OTP expires in 5 minutes
-
- otpEntry, err := database.QueryOTPEntry(email)
-
+func verifyRequestedOTP(c *gin.Context, purpose string) (string, bool) {
+ email, err := requestedOTPEmail(c)
if err != nil {
- if errors.Is(err, gorm.ErrRecordNotFound) {
- otpEntry = database.OTP{
- Email: email,
- Code: otp,
- Expiry: expiry,
- }
- } else {
- log.Println("Failed to query OTP:", err)
- c.JSON(http.StatusInternalServerError, HTTPErrorResp{
- Error: "Failed to send OTP",
- })
- return
- }
+ c.JSON(http.StatusBadRequest, HTTPErrorResp{Error: "A valid email address is required"})
+ return "", false
}
-
- otpEntry.Code = otp
- otpEntry.Expiry = expiry
-
- err = database.CreateOTPEntry(&otpEntry)
+ code := strings.TrimSpace(c.PostForm("otp"))
+ if len(code) != 6 || strings.IndexFunc(code, func(r rune) bool { return r < '0' || r > '9' }) >= 0 {
+ c.JSON(http.StatusUnauthorized, HTTPErrorResp{Error: "Invalid OTP"})
+ return "", false
+ }
+ err = database.VerifyOTPCode(email, purpose, otpCodeHash(email, purpose, code), time.Now())
if err != nil {
- log.Println("Failed to store OTP:", err)
- c.JSON(http.StatusInternalServerError, HTTPErrorResp{
- Error: "Failed to store OTP",
- })
- return
+ switch {
+ case errors.Is(err, database.ErrOTPAttempts):
+ c.JSON(http.StatusTooManyRequests, HTTPErrorResp{Error: "Too many OTP attempts"})
+ case errors.Is(err, database.ErrOTPInvalid), errors.Is(err, database.ErrOTPExpired), errors.Is(err, gorm.ErrRecordNotFound):
+ c.JSON(http.StatusUnauthorized, HTTPErrorResp{Error: "Invalid or expired OTP"})
+ default:
+ log.Println("Failed to verify OTP:", err)
+ c.JSON(http.StatusInternalServerError, HTTPErrorResp{Error: "Failed to verify OTP"})
+ }
+ return "", false
}
+ return email, true
+}
- // Send OTP to email
- err = sendEmail(email, otp)
- if err != nil {
- log.Println("Failed to send OTP:", err)
- c.JSON(http.StatusInternalServerError, HTTPErrorResp{
- Error: "Failed to send OTP",
- })
+// @Summary Send an email verification OTP
+// @Tags auth
+// @Accept multipart/form-data
+// @Produce json
+// @Param email formData string true "Email address"
+// @Success 200 {object} api.HTTPPlainResp
+// @Failure 400 {object} api.HTTPErrorResp
+// @Router /auth/send-otp [post]
+func sendOTPHandler(c *gin.Context) {
+ issueOTP(c, otpPurposeRegistration, false)
+}
+
+// @Summary Verify an email OTP
+// @Tags auth
+// @Accept multipart/form-data
+// @Produce json
+// @Param email formData string true "Email address"
+// @Param otp formData string true "One-time code"
+// @Success 200 {object} api.HTTPPlainResp
+// @Failure 400 {object} api.HTTPErrorResp
+// @Router /auth/verify-otp [post]
+func verifyOTPHandler(c *gin.Context) {
+ if _, ok := verifyRequestedOTP(c, otpPurposeRegistration); !ok {
return
}
+ c.JSON(http.StatusOK, HTTPPlainResp{Message: "OTP verified successfully"})
+}
- c.JSON(http.StatusOK, HTTPPlainResp{
- Message: "OTP sent successfully",
- })
+// @Summary Send a password-reset OTP
+// @Tags auth
+// @Accept multipart/form-data
+// @Produce json
+// @Param email formData string true "Registered email address"
+// @Success 200 {object} api.HTTPPlainResp
+// @Failure 400 {object} api.HTTPErrorResp
+// @Router /auth/send-otp-forget [post]
+func sendOTPForForgetHandler(c *gin.Context) {
+ issueOTP(c, otpPurposePasswordReset, true)
}
+// @Summary Verify a password-reset OTP
+// @Tags auth
+// @Accept multipart/form-data
+// @Produce json
+// @Param email formData string true "Registered email address"
+// @Param otp formData string true "One-time code"
+// @Success 200 {object} api.HTTPAuthorizeResp
+// @Failure 400 {object} api.HTTPErrorResp
+// @Router /auth/verify-otp-forget [post]
func verifyOTPForForgetHandler(c *gin.Context) {
- email := c.PostForm("email")
- otp := strings.TrimSpace(c.PostForm("otp"))
- email = strings.TrimSpace(strings.ToLower(email))
- if !config.SkipAuthorization {
- smtpHost := config.Cfg.MailConfig.SMTPHost
- smtpPort := config.Cfg.MailConfig.SMTPPort
-
- if smtpHost == "" || smtpPort == "" {
- log.Printf("WARNING: %s", "SMTP not configured")
- c.JSON(http.StatusInternalServerError, HTTPErrorResp{
- Error: "SMTP not configured",
- })
- return
- }
-
- otpEntry, err := database.QueryOTPEntry(email)
- if err != nil {
- if errors.Is(err, gorm.ErrRecordNotFound) {
- c.JSON(http.StatusUnauthorized, HTTPErrorResp{
- Error: "OTP not found",
- })
- } else {
- log.Println("Failed to query OTP:", err)
- c.JSON(http.StatusInternalServerError, HTTPErrorResp{
- Error: "Failed to verify OTP",
- })
- }
- return
- }
-
- if otpEntry.Code != otp {
- c.JSON(http.StatusUnauthorized, HTTPErrorResp{
- Error: "Invalid OTP",
- })
- return
- }
-
- if time.Now().After(otpEntry.Expiry) {
- c.JSON(http.StatusUnauthorized, HTTPErrorResp{
- Error: "OTP expired",
- })
- return
- }
+ email, ok := verifyRequestedOTP(c, otpPurposePasswordReset)
+ if !ok {
+ return
}
userEntry, err := database.QueryFirstUserEntry("email", email)
if err != nil {
@@ -428,17 +363,7 @@ func verifyOTPForForgetHandler(c *gin.Context) {
return
}
- t := time.Now().Unix()
-
- token := jwt.NewWithClaims(jwt.SigningMethodHS256, auth.CustomClaims{
- User: userEntry.Username,
- Role: userEntry.Role,
- ExpiresAt: t + 300,
- IssuedAt: t,
- Issuer: auth.ISSUER,
- })
-
- tempToken, err := token.SignedString([]byte(auth.JWTSECRET))
+ tempToken, err := auth.GeneratePasswordResetJWT(userEntry.Username, userEntry.Role, 5*time.Minute)
if err != nil {
c.JSON(http.StatusInternalServerError, HTTPErrorResp{
diff --git a/api/otp_test.go b/api/otp_test.go
new file mode 100644
index 00000000..8dc645be
--- /dev/null
+++ b/api/otp_test.go
@@ -0,0 +1,43 @@
+package api
+
+import (
+ "bytes"
+ "regexp"
+ "testing"
+
+ "github.com/sdslabs/beastv4/core/config"
+)
+
+func TestGenerateOTPProducesSixDigits(t *testing.T) {
+ otp, err := generateOTP()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !regexp.MustCompile(`^[0-9]{6}$`).MatchString(otp) {
+ t.Fatalf("unexpected OTP format %q", otp)
+ }
+}
+
+func TestOTPHashBindsPurposeAndEmail(t *testing.T) {
+ previous := config.Cfg
+ config.Cfg = &config.BeastConfig{JWTSecret: "01234567890123456789012345678901"}
+ t.Cleanup(func() { config.Cfg = previous })
+
+ registration := otpCodeHash("user@example.com", otpPurposeRegistration, "123456")
+ reset := otpCodeHash("user@example.com", otpPurposePasswordReset, "123456")
+ otherUser := otpCodeHash("other@example.com", otpPurposeRegistration, "123456")
+ if bytes.Equal(registration, reset) || bytes.Equal(registration, otherUser) {
+ t.Fatal("OTP hash was not bound to purpose and email")
+ }
+}
+
+func TestCanonicalMailboxRejectsHeaderInjection(t *testing.T) {
+ for _, value := range []string{"Name ", "user@example.com\r\nBcc: victim@example.com", "not-an-email"} {
+ if _, err := canonicalMailbox(value); err == nil {
+ t.Fatalf("expected mailbox %q to be rejected", value)
+ }
+ }
+ if got, err := canonicalMailbox("user@example.com"); err != nil || got != "user@example.com" {
+ t.Fatalf("expected canonical mailbox, got %q, %v", got, err)
+ }
+}
diff --git a/api/rate_limit.go b/api/rate_limit.go
new file mode 100644
index 00000000..0eeb643c
--- /dev/null
+++ b/api/rate_limit.go
@@ -0,0 +1,81 @@
+package api
+
+import (
+ "context"
+ "crypto/sha256"
+ "fmt"
+ "net"
+ "net/http"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/sdslabs/beastv4/core/cache"
+)
+
+const loginRateWindow = 5 * time.Minute
+
+const incrementWithExpiryScript = `
+local count = redis.call("INCR", KEYS[1])
+if count == 1 then
+ redis.call("PEXPIRE", KEYS[1], ARGV[1])
+end
+return count
+`
+
+func rateLimitKey(kind, value string) string {
+ digest := sha256.Sum256([]byte(kind + "\x00" + value))
+ return fmt.Sprintf("beast:rate:%s:%x", kind, digest)
+}
+
+func incrementRateLimit(ctx context.Context, key string, window time.Duration) (int64, error) {
+ if cache.Cache == nil {
+ return 0, fmt.Errorf("cache is unavailable")
+ }
+ return cache.Cache.Eval(ctx, incrementWithExpiryScript, []string{key}, window.Milliseconds()).Int64()
+}
+
+func enforceLoginRateLimit(c *gin.Context, username string) bool {
+ host := requestPeerHost(c)
+ ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
+ defer cancel()
+
+ usernameCount, err := incrementRateLimit(ctx, rateLimitKey("login-user", username), loginRateWindow)
+ if err == nil {
+ var addressCount int64
+ addressCount, err = incrementRateLimit(ctx, rateLimitKey("login-address", host), loginRateWindow)
+ if err == nil && usernameCount <= 10 && addressCount <= 50 {
+ return true
+ }
+ }
+ if err != nil {
+ c.AbortWithStatusJSON(http.StatusServiceUnavailable, HTTPErrorResp{Error: "Authentication service unavailable"})
+ return false
+ }
+ c.Header("Retry-After", "300")
+ c.AbortWithStatusJSON(http.StatusTooManyRequests, HTTPErrorResp{Error: "Too many login attempts"})
+ return false
+}
+
+func enforceOTPSendRateLimit(c *gin.Context) bool {
+ ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
+ defer cancel()
+ count, err := incrementRateLimit(ctx, rateLimitKey("otp-address", requestPeerHost(c)), 10*time.Minute)
+ if err != nil {
+ c.AbortWithStatusJSON(http.StatusServiceUnavailable, HTTPErrorResp{Error: "OTP service unavailable"})
+ return false
+ }
+ if count > 10 {
+ c.Header("Retry-After", "600")
+ c.AbortWithStatusJSON(http.StatusTooManyRequests, HTTPErrorResp{Error: "Too many OTP requests"})
+ return false
+ }
+ return true
+}
+
+func requestPeerHost(c *gin.Context) string {
+ host, _, err := net.SplitHostPort(c.Request.RemoteAddr)
+ if err != nil {
+ return c.Request.RemoteAddr
+ }
+ return host
+}
diff --git a/api/rate_limit_test.go b/api/rate_limit_test.go
new file mode 100644
index 00000000..c3960b59
--- /dev/null
+++ b/api/rate_limit_test.go
@@ -0,0 +1,16 @@
+package api
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestRateLimitKeyDoesNotExposeIdentity(t *testing.T) {
+ key := rateLimitKey("login-user", "sensitive-user")
+ if strings.Contains(key, "sensitive-user") {
+ t.Fatalf("rate limit key leaks identity: %q", key)
+ }
+ if key != rateLimitKey("login-user", "sensitive-user") {
+ t.Fatal("rate limit key is not deterministic")
+ }
+}
diff --git a/api/remote.go b/api/remote.go
index 8066e645..f9995797 100644
--- a/api/remote.go
+++ b/api/remote.go
@@ -18,7 +18,7 @@ import (
// @Param Authorization header string true "Bearer"
// @Success 200 {object} api.HTTPPlainResp
// @Failure 500 {object} api.HTTPPlainResp
-// @Router /api/remote/sync/ [post]
+// @Router /api/remote/sync [post]
func syncBeastGitRemote(c *gin.Context) {
err := manager.SyncBeastRemote("")
if err != nil {
@@ -43,7 +43,7 @@ func syncBeastGitRemote(c *gin.Context) {
// @Param Authorization header string true "Bearer"
// @Success 200 {object} api.HTTPPlainResp
// @Failure 500 {object} api.HTTPPlainResp
-// @Router /api/remote/reset/ [post]
+// @Router /api/remote/reset [post]
func resetBeastGitRemote(c *gin.Context) {
err := manager.ResetBeastRemote("")
if err != nil {
diff --git a/api/request_limit_test.go b/api/request_limit_test.go
new file mode 100644
index 00000000..acc55672
--- /dev/null
+++ b/api/request_limit_test.go
@@ -0,0 +1,24 @@
+package api
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+)
+
+func TestLimitRequestBodyRejectsOversizePayload(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ router := gin.New()
+ router.Use(limitRequestBody)
+ router.POST("/test", func(c *gin.Context) { c.Status(http.StatusNoContent) })
+
+ request := httptest.NewRequest(http.MethodPost, "/test", strings.NewReader(strings.Repeat("x", int(maxAPIRequestBytes+1))))
+ response := httptest.NewRecorder()
+ router.ServeHTTP(response, request)
+ if response.Code != http.StatusRequestEntityTooLarge {
+ t.Fatalf("status = %d, want %d", response.Code, http.StatusRequestEntityTooLarge)
+ }
+}
diff --git a/api/response.go b/api/response.go
index 04d0dce8..111f7d81 100644
--- a/api/response.go
+++ b/api/response.go
@@ -24,13 +24,13 @@ type HTTPAuthorizeResp struct {
type AvailableImagesResp struct {
Message string `json:"message" example:"Available Base images."`
- Images []string `json:"images" example:"['ubuntu16.04', 'ubuntu18.04']"`
+ Images []string `json:"images" example:"ubuntu:24.04,debian:bookworm"`
}
type PortsInUseResp struct {
MinPortValue uint32 `json:"port_min_value" example:"10000"`
MaxPortValue uint32 `json:"port_max_value" example:"20000"`
- PortsInUse []uint32 `json:"ports_in_use" example:"[100001, 100003, 10010]"`
+ PortsInUse []uint32 `json:"ports_in_use" example:"10001,10003,10010"`
}
type ChallengeStatusResp struct {
@@ -82,7 +82,7 @@ type ChallengeSolveResp struct {
Id uint `json:"id" example:"4"`
Name string `json:"name" example:"Web Challenge"`
Category string `json:"category" example:"bare"`
- Tags []string `json:"tags" example:"['pwn','misc']"`
+ Tags []string `json:"tags" example:"pwn,misc"`
SolvedAt time.Time `json:"solvedAt"`
Points uint `json:"points" example:"50"`
}
@@ -108,14 +108,14 @@ type HintResponse struct {
type ChallengeMetadata struct {
ChallId uint `json:"id" example:"0"`
Name string `json:"name" example:"Web Challenge"`
- Tags []string `json:"tags" example:"['pwn','misc']"`
+ Tags []string `json:"tags" example:"pwn,misc"`
Points uint `json:"points" example:"50"`
Difficulty string `json:"difficulty" example:"easy"`
SolvesNumber uint16 `json:"solvesNumber" example:"100"`
SolveStatus bool `json:"solveStatus" example:"True"`
CreatedAt time.Time `json:"createdAt"`
DeployedStatus string `json:"deployedStatus" example:"deployed"`
- PreRequisite []string `json:"preRequisite" example:"['chall1', chall2]"`
+ PreRequisite []string `json:"preRequisite" example:"chall1,chall2"`
Instanced bool `json:"instanced" example:"false"`
InstanceExpiration int64 `json:"instanceExpiration" example:"300"`
}
@@ -126,8 +126,8 @@ type Challenge struct {
Description string `json:"description" example:"A simple web challenge"`
Hints []HintInfo `json:"hints"`
Category string `json:"category" example:"web"`
- Assets []string `json:"assets" example:"['image1.png', 'zippy.zip']"`
- AdditionalLinks []string `json:"additionalLinks" example:"['http://link1.abc:8080','http://link2.abc:8081']"`
+ Assets []string `json:"assets" example:"image1.png,zippy.zip"`
+ AdditionalLinks []string `json:"additionalLinks" example:"https://link1.example,https://link2.example"`
PreviousTries int `json:"previousTries" example:"3"`
MaxAttemptLimit int `json:"maxAttemptLimit" example:"5"`
DeployedLink string `json:"deployedLink" example:"beast.sdslabs.co or ip:port"`
@@ -143,12 +143,12 @@ type AdminChallenge struct {
type ChallengePreviewResp struct {
Name string `json:"name" example:"Web Challenge"`
Category string `json:"category" example:"web"`
- Tags []string `json:"tags" example:"['pwn','misc']"`
- Assets []string `json:"assets" example:"['image1.png', 'zippy.zip']"`
- AdditionalLinks []string `json:"additionalLinks" example:"['http://link1.abc:8080','http://link2.abc:8081']"`
+ Tags []string `json:"tags" example:"pwn,misc"`
+ Assets []string `json:"assets" example:"image1.png,zippy.zip"`
+ AdditionalLinks []string `json:"additionalLinks" example:"https://link1.example,https://link2.example"`
MaxAttemptLimit int `json:"maxAttemptLimit" example:"5"`
- PreReqs []string `json:"preRequisite" example:"['web-php','simple']"`
- Ports []uint32 `json:"ports" example:"[3001, 3002]"`
+ PreReqs []string `json:"preRequisite" example:"web-php,simple"`
+ Ports []uint32 `json:"ports" example:"3001,3002"`
Desc string `json:"description" example:"A simple web challenge"`
Points uint `json:"points" example:"50"`
DeployedLink string `json:"deployedLink" example:"beast.sdslabs.co"`
diff --git a/api/router.go b/api/router.go
index ef667fab..9cda0b5d 100644
--- a/api/router.go
+++ b/api/router.go
@@ -1,6 +1,7 @@
package api
import (
+ "io"
"net/http"
"path/filepath"
"time"
@@ -9,6 +10,8 @@ import (
"github.com/gin-contrib/static"
"github.com/gin-gonic/gin"
"github.com/sdslabs/beastv4/core"
+ "github.com/sdslabs/beastv4/core/config"
+ log "github.com/sirupsen/logrus"
)
func dummyHandler(c *gin.Context) {
@@ -17,24 +20,46 @@ func dummyHandler(c *gin.Context) {
})
}
+const maxAPIRequestBytes int64 = 2 << 20
+
+func limitRequestBody(c *gin.Context) {
+ limit := maxAPIRequestBytes
+ if c.Request.URL.Path == "/api/manage/challenge/upload" {
+ limit = maxChallengeUploadRequestBytes
+ }
+ if c.Request.ContentLength > limit {
+ c.AbortWithStatusJSON(http.StatusRequestEntityTooLarge, HTTPErrorResp{Error: "Request body is too large"})
+ return
+ }
+ c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, limit)
+ c.Next()
+}
+
func initGinRouter() *gin.Engine {
router := gin.New()
-
- corsConfig := cors.Config{
- AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE"},
- AllowHeaders: []string{"Origin", "Content-Length", "Content-Type", "Authorization", "Cookie"},
- AllowCredentials: false,
- AllowAllOrigins: true,
- MaxAge: 12 * time.Hour,
+ router.Use(gin.CustomRecoveryWithWriter(io.Discard, func(c *gin.Context, _ interface{}) {
+ log.Error("request handler panic recovered")
+ c.AbortWithStatusJSON(http.StatusInternalServerError, HTTPErrorResp{Error: "Internal server error"})
+ }))
+
+ if len(config.Cfg.ServerConfig.AllowedOrigins) > 0 {
+ corsConfig := cors.Config{
+ AllowOrigins: config.Cfg.ServerConfig.AllowedOrigins,
+ AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE"},
+ AllowHeaders: []string{"Origin", "Content-Length", "Content-Type", "Authorization"},
+ AllowCredentials: false,
+ MaxAge: 12 * time.Hour,
+ }
+ router.Use(cors.New(corsConfig))
}
- router.Use(cors.New(corsConfig))
+ router.Use(limitRequestBody)
router.GET("/dummy", dummyHandler)
// Authorization routes group
authGroup := router.Group("/auth")
{
authGroup.POST("/register", register)
authGroup.POST("/login", login)
- authGroup.POST("/reset-password", authorize, resetPasswordHandler)
+ authGroup.POST("/reset-password", resetPasswordAuthorize, resetPasswordHandler)
authGroup.POST("/send-otp", sendOTPHandler)
authGroup.POST("/verify-otp", verifyOTPHandler)
authGroup.POST("/send-otp-forget", sendOTPForForgetHandler)
@@ -55,17 +80,18 @@ func initGinRouter() *gin.Engine {
// Deploy route group
manageGroup := apiGroup.Group("/manage", managerAuthorize)
{
- manageGroup.POST("/deploy/local/", deployLocalChallengeHandler)
+ manageGroup.POST("/deploy/local/", adminAuthorize, deployLocalChallengeHandler)
manageGroup.POST("/challenge/", manageChallengeHandler)
- manageGroup.POST("/challenge/multiple/", manageMultipleChallengeHandlerNameBased)
- manageGroup.POST("/multiple/:action", manageMultipleChallengeHandlerTagBased)
- manageGroup.POST("/static/:action", beastStaticContentHandler)
+ manageGroup.POST("/challenge/multiple/", adminAuthorize, manageMultipleChallengeHandlerNameBased)
+ manageGroup.POST("/multiple/:action", adminAuthorize, manageMultipleChallengeHandlerTagBased)
+ manageGroup.POST("/static/:action", adminAuthorize, beastStaticContentHandler)
manageGroup.POST("/commit/", commitChallenge)
manageGroup.POST("/challenge/verify", verifyHandler)
- manageGroup.POST("/schedule/:action", manageScheduledAction)
+ manageGroup.POST("/schedule/:action", adminAuthorize, manageScheduledAction)
manageGroup.POST("/challenge/upload", manageUploadHandler)
manageGroup.POST("/challenge/validateflag", validateFlagHandler)
manageGroup.GET("/logs", challengeLogsHandler)
+ manageGroup.POST("/challenge/:name/exec", execChallengeHandler)
}
// Status route group
@@ -81,12 +107,12 @@ func initGinRouter() *gin.Engine {
{
infoGroup.GET("/challenge/:name", challengeInfoHandler)
infoGroup.GET("/challenges", challengesMetadataHandler)
- // infoGroup.GET("/images/available", availableImagesHandler)
+ infoGroup.GET("/images/available", availableImagesHandler)
// infoGroup.GET("/ports/used", usedPortsInfoHandler)
infoGroup.GET("/user/:username", userInfoHandler)
infoGroup.GET("/users", getAllUsersInfoHandler)
infoGroup.GET("/leaderboard", getLeaderboardHandler)
- infoGroup.GET("leaderboard-graph", getLeaderboardGraphHandler)
+ infoGroup.GET("/leaderboard-graph", getLeaderboardGraphHandler)
infoGroup.GET("/usercount", getUserCountHandler)
infoGroup.GET("/submissions/challenge/:challenge_id", getChallengeAttempts)
infoGroup.GET("/submissions/user/:user_id", getUserAttempts)
@@ -114,8 +140,6 @@ func initGinRouter() *gin.Engine {
configGroup := apiGroup.Group("/config", adminAuthorize)
{
- configGroup.PATCH("/reload", reloadBeastConfig)
- configGroup.POST("/competition-info", updateCompetitionInfoHandler)
configGroup.POST("/challenge-info", updateChallengeInfoHandler)
}
diff --git a/api/status.go b/api/status.go
index 54ae4943..4d181ce5 100644
--- a/api/status.go
+++ b/api/status.go
@@ -22,7 +22,7 @@ import (
// @Success 200 {object} api.ChallengeStatusResp
// @Failure 500 {object} api.HTTPPlainResp
// @Failure 400 {object} api.HTTPPlainResp
-// @Router /api/status/challenge/:name [get]
+// @Router /api/status/challenge/{name} [get]
func challengeStatusHandler(c *gin.Context) {
name := c.Param("name")
if name == "" {
@@ -67,7 +67,8 @@ func challengeStatusHandler(c *gin.Context) {
// @Failure 500 {object} api.HTTPPlainResp
// @Failure 400 {object} api.HTTPPlainResp
// @Success 200 {array} api.ChallengeStatusResp
-// @Router /api/status/all/:filter [get]
+// @Router /api/status/all/{filter} [get]
+// @Router /api/status/all [get]
func statusHandler(c *gin.Context) {
filter := c.Param("filter")
diff --git a/api/submit.go b/api/submit.go
index c08b4373..1334f44a 100644
--- a/api/submit.go
+++ b/api/submit.go
@@ -1,10 +1,11 @@
package api
import (
+ "context"
+ "fmt"
"math"
"net/http"
"strconv"
- "sync"
"time"
"github.com/gin-gonic/gin"
@@ -17,8 +18,7 @@ import (
)
var (
- dynamicScoreWorkerOnce sync.Once
- dynamicScoreNotify = make(chan struct{}, 1)
+ dynamicScoreNotify = make(chan struct{}, 1)
)
// Verifies and creates an entry in the database for successful submission of flag for a challenge.
@@ -198,8 +198,13 @@ func submitFlagHandler(c *gin.Context) {
return
}
if claim.Status == database.DynamicFlagClaimedByOtherUser {
- subuser, _ := database.QueryUserById(claim.ClaimedByID)
- msg := "User " + user.Username + " has submitted the flag " + flag + " for challenge " + challenge.Name + " which has already been claimed by user " + subuser.Username
+ claimedBy := fmt.Sprintf("ID %d", claim.ClaimedByID)
+ if subuser, lookupErr := database.QueryUserById(claim.ClaimedByID); lookupErr != nil {
+ log.Warnf("failed to resolve dynamic flag claimant %d: %v", claim.ClaimedByID, lookupErr)
+ } else {
+ claimedBy = subuser.Username
+ }
+ msg := fmt.Sprintf("User %s submitted a duplicate dynamic flag for challenge %s already claimed by user %s", user.Username, challenge.Name, claimedBy)
go notify.SendNotification(notify.Warning, msg)
if err := database.MarkSubmissionCheating(user.ID, challenge.ID, flag); err != nil {
log.Warnf("failed to mark duplicate dynamic flag submission as cheating: %v", err)
@@ -250,9 +255,7 @@ func submitFlagHandler(c *gin.Context) {
}
}
- leaderboardStale = true
- graphCacheStale = true
- adminLeaderboardStale = true
+ markLeaderboardCachesStale()
c.JSON(http.StatusOK, FlagSubmitResp{
Message: "Your flag is correct",
@@ -269,22 +272,33 @@ func dynamicScore(maxPoints, minPoints, solvers uint) uint {
return uint(math.Round(float64(minPoints) + (float64(maxPoints)-float64(minPoints))/divisor))
}
-func startDynamicScoreWorker() {
- dynamicScoreWorkerOnce.Do(func() {
- go func() {
- ticker := time.NewTicker(30 * time.Second)
- defer ticker.Stop()
-
- for {
- select {
- case <-dynamicScoreNotify:
- processDirtyDynamicScores()
- case <-ticker.C:
- processDirtyDynamicScores()
- }
+func scoreAfterPointChange(currentScore, newPoints, oldPoints uint) uint {
+ score := int64(currentScore) + int64(newPoints) - int64(oldPoints)
+ if score < 0 {
+ return 0
+ }
+ return uint(score)
+}
+
+func startDynamicScoreWorker(ctx context.Context) <-chan struct{} {
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ ticker := time.NewTicker(30 * time.Second)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-dynamicScoreNotify:
+ processDirtyDynamicScores()
+ case <-ticker.C:
+ processDirtyDynamicScores()
}
- }()
- })
+ }
+ }()
+ return done
}
func notifyDynamicScoreWorker() {
@@ -339,9 +353,7 @@ func recomputeDynamicScore(dirty database.DynamicScoreDirty) error {
if delta != 0 {
log.Debugf("By dynamic scoring the points of challenge %s are changed to %d from %d", challenge.Name, newPoints, challenge.Points)
- leaderboardStale = true
- graphCacheStale = true
- adminLeaderboardStale = true
+ markLeaderboardCachesStale()
}
return nil
@@ -356,28 +368,17 @@ func updatePointsOfSolvers(submissions []database.UserChallenges, newChallengePo
return err
}
if user.Role == "contestant" {
- oldScore := user.Score
- newScore := user.Score + (newChallengePointsAfterSolve - oldChallengePointsBeforeSolve)
- if newScore <= 0 {
- newScore = 0
- }
+ newScore := scoreAfterPointChange(user.Score, newChallengePointsAfterSolve, oldChallengePointsBeforeSolve)
err = database.UpdateUser(&user, map[string]interface{}{"Score": newScore})
if err != nil {
return err
}
- // Check if this user's score change could affect top 25 leaderboard
- if !scoreChanged && (len(adminLeaderboardCache) < core.LEADERBOARD_SIZE ||
- (len(adminLeaderboardCache) > 0 && (oldScore >= adminLeaderboardCache[len(adminLeaderboardCache)-1].Score ||
- newScore >= adminLeaderboardCache[len(adminLeaderboardCache)-1].Score))) {
- scoreChanged = true
- }
+ scoreChanged = true
}
}
// Mark cache stale if any user's score change could affect top 25
if scoreChanged {
- leaderboardStale = true
- graphCacheStale = true
- adminLeaderboardStale = true
+ markLeaderboardCachesStale()
}
return nil
}
diff --git a/api/submit_test.go b/api/submit_test.go
new file mode 100644
index 00000000..9aecdb3d
--- /dev/null
+++ b/api/submit_test.go
@@ -0,0 +1,22 @@
+package api
+
+import "testing"
+
+func TestScoreAfterPointChangeDoesNotUnderflow(t *testing.T) {
+ tests := []struct {
+ name string
+ current, new, old uint
+ want uint
+ }{
+ {name: "increase", current: 100, new: 75, old: 50, want: 125},
+ {name: "decrease", current: 100, new: 25, old: 50, want: 75},
+ {name: "clamp at zero", current: 10, new: 0, old: 50, want: 0},
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ if got := scoreAfterPointChange(test.current, test.new, test.old); got != test.want {
+ t.Fatalf("scoreAfterPointChange() = %d, want %d", got, test.want)
+ }
+ })
+ }
+}
diff --git a/api/upload_test.go b/api/upload_test.go
new file mode 100644
index 00000000..e15d2eaf
--- /dev/null
+++ b/api/upload_test.go
@@ -0,0 +1,59 @@
+package api
+
+import (
+ "errors"
+ "mime/multipart"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/sdslabs/beastv4/core"
+)
+
+func TestChallengeArchiveFilenameRejectsUnsafeNames(t *testing.T) {
+ for _, name := range []string{"", ".zip", "challenge.tar", "../challenge.zip", `dir\\challenge.zip`} {
+ if _, err := challengeArchiveFilename(name); err == nil {
+ t.Fatalf("expected filename %q to be rejected", name)
+ }
+ }
+ if got, err := challengeArchiveFilename("challenge.ZIP"); err != nil || got != "challenge.ZIP" {
+ t.Fatalf("expected valid ZIP name, got %q, %v", got, err)
+ }
+}
+
+func TestSaveChallengeArchiveRejectsOversizeHeader(t *testing.T) {
+ header := &multipart.FileHeader{Filename: "challenge.zip", Size: maxChallengeUploadBytes + 1}
+ err := saveChallengeArchive(header, filepath.Join(t.TempDir(), "challenge.zip"))
+ if !errors.Is(err, errChallengeUploadTooLarge) {
+ t.Fatalf("expected upload size error, got %v", err)
+ }
+}
+
+func TestPersistUploadedChallengeDoesNotReplaceExistingChallenge(t *testing.T) {
+ previousGlobalDir := core.BEAST_GLOBAL_DIR
+ core.BEAST_GLOBAL_DIR = t.TempDir()
+ t.Cleanup(func() { core.BEAST_GLOBAL_DIR = previousGlobalDir })
+
+ source := t.TempDir()
+ if err := os.WriteFile(filepath.Join(source, "beast.toml"), []byte("original"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if err := persistUploadedChallenge(source, "challenge"); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(source, "beast.toml"), []byte("replacement"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if err := persistUploadedChallenge(source, "challenge"); !errors.Is(err, os.ErrExist) {
+ t.Fatalf("expected existing challenge error, got %v", err)
+ }
+
+ stored := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_UPLOADS_DIR, "challenge", "beast.toml")
+ contents, err := os.ReadFile(stored)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(contents) != "original" {
+ t.Fatalf("existing challenge was replaced: %q", contents)
+ }
+}
diff --git a/azure-pipelines.yml b/azure-pipelines.yml
index d48d602b..2496ea36 100644
--- a/azure-pipelines.yml
+++ b/azure-pipelines.yml
@@ -1,52 +1,27 @@
trigger:
+- main
- master
pr:
+- main
- master
- releases/*
pool:
vmImage: 'ubuntu-latest'
-variables:
- GOPATH: '$(system.defaultWorkingDirectory)/gopath'
-
steps:
- task: GoTool@0
inputs:
- version: '1.13.5'
+ version: '1.23.0'
- script: |
- sudo apt update
- sudo apt install docker.io
- sudo usermod -a -G docker $USER
- sudo systemctl unmask docker
- sudo systemctl start docker
- displayName: 'Set up the Docker workspace'
+ make check_format
+ go vet ./...
+ go test -race ./...
+ displayName: 'Validate and test'
workingDirectory: '$(System.DefaultWorkingDirectory)'
-- script: |
- mkdir ~/.beast
- cp _examples/example.config.toml ~/.beast/config.toml
- touch ~/.beast/secret.key
- touch ~/.beast/authorized_keys
- mkdir ~/.beast/staging
- mkdir ~/.beast/remote
- mkdir ~/.beast/scripts
- displayName: 'Setup Beast Global Directory'
-
- script: |
make build
workingDirectory: '$(System.DefaultWorkingDirectory)'
displayName: 'Build Beast'
-
-- script: |
- make requirements
- workingDirectory: '$(System.DefaultWorkingDirectory)'
- displayName: 'Build Requirements'
-
-- script: |
- echo -ne "ssh-rsa AAAAB3NzaC1y" > pub.key
- $GOPATH/bin/beast create-author --name fristonio --email contact+fristonio@sdslabs.co.in --publickey pub.key -v --username fristonio --password pass123
- make test
- workingDirectory: '$(System.DefaultWorkingDirectory)'
- displayName: 'Run tests'
diff --git a/client/authorize.go b/client/authorize.go
index ee080f1b..6a3b426c 100644
--- a/client/authorize.go
+++ b/client/authorize.go
@@ -1,65 +1,78 @@
package client
import (
+ "crypto/tls"
+ "crypto/x509"
"encoding/json"
"fmt"
- "io/ioutil"
+ "io"
"net/http"
"net/url"
+ "os"
+ "time"
)
+const maxAuthorizeResponseBytes = 1 << 20
+
type Response struct {
Message string `json:"message"`
Challenge []byte `json:"challenge"`
Token string `json:"token"`
}
-func Authorize(password string, host string, username string) {
-
- u, err := url.Parse("auth/login")
- if err != nil {
- fmt.Printf("Error while parsing url : %v", err)
- return
- }
-
+func Authorize(password, host, username, caFile string) (Response, error) {
base, err := url.Parse(host)
- if err != nil {
- fmt.Printf("Error while parsing url : %v", err)
- return
+ if err != nil || base.Scheme != "https" || base.Host == "" || base.User != nil {
+ return Response{}, fmt.Errorf("Beast host must be a valid HTTPS URL")
}
-
- res, err := http.PostForm(base.ResolveReference(u).String(), url.Values{
+ tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12}
+ if caFile != "" {
+ certificate, err := os.ReadFile(caFile)
+ if err != nil {
+ return Response{}, fmt.Errorf("read CA file: %w", err)
+ }
+ roots, err := x509.SystemCertPool()
+ if err != nil || roots == nil {
+ roots = x509.NewCertPool()
+ }
+ if !roots.AppendCertsFromPEM(certificate) {
+ return Response{}, fmt.Errorf("CA file contains no certificates")
+ }
+ tlsConfig.RootCAs = roots
+ }
+ httpClient := &http.Client{
+ Timeout: 15 * time.Second,
+ Transport: &http.Transport{TLSClientConfig: tlsConfig},
+ CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
+ return http.ErrUseLastResponse
+ },
+ }
+ endpoint := base.ResolveReference(&url.URL{Path: "auth/login"})
+ response, err := httpClient.PostForm(endpoint.String(), url.Values{
"username": {username},
"password": {password},
})
if err != nil {
- fmt.Printf("Error while making post request : %v", err)
- return
+ return Response{}, fmt.Errorf("authorize request: %w", err)
}
-
- body, err := ioutil.ReadAll(res.Body)
+ defer response.Body.Close()
+ body, err := io.ReadAll(io.LimitReader(response.Body, maxAuthorizeResponseBytes+1))
if err != nil {
- fmt.Printf("Error while making post request : %v", err)
- return
+ return Response{}, fmt.Errorf("read authorize response: %w", err)
}
-
- if res.StatusCode != http.StatusOK {
- fmt.Printf("Response code : %v \nBody : %v", res.StatusCode, string(body))
- return
+ if len(body) > maxAuthorizeResponseBytes {
+ return Response{}, fmt.Errorf("authorize response exceeds 1 MiB")
}
-
- var response Response
-
- err = json.Unmarshal(body, &response)
- if err != nil {
- fmt.Printf("Error while parsing response : %v", err)
- return
+ if response.StatusCode != http.StatusOK {
+ return Response{}, fmt.Errorf("authorization failed with HTTP status %d", response.StatusCode)
}
- fmt.Printf(`
-The response:
-Token : %v
-Message : %v
- `, response.Token, response.Message)
-
+ var result Response
+ if err := json.Unmarshal(body, &result); err != nil {
+ return Response{}, fmt.Errorf("parse authorize response: %w", err)
+ }
+ if result.Token == "" {
+ return Response{}, fmt.Errorf("authorize response did not contain a token")
+ }
+ return result, nil
}
diff --git a/client/authorize_test.go b/client/authorize_test.go
new file mode 100644
index 00000000..1591be85
--- /dev/null
+++ b/client/authorize_test.go
@@ -0,0 +1,9 @@
+package client
+
+import "testing"
+
+func TestAuthorizeRequiresHTTPS(t *testing.T) {
+ if _, err := Authorize("password", "http://localhost:5005", "user", ""); err == nil {
+ t.Fatal("expected plaintext authorization URL to fail")
+ }
+}
diff --git a/cmd/beast/authorize.go b/cmd/beast/authorize.go
index 5a2962d6..eaa57535 100644
--- a/cmd/beast/authorize.go
+++ b/cmd/beast/authorize.go
@@ -2,28 +2,30 @@ package main
import (
"fmt"
- "os"
+ "strings"
"github.com/sdslabs/beastv4/client"
+ "github.com/sdslabs/beastv4/utils"
"github.com/spf13/cobra"
)
var getAuthCmd = &cobra.Command{
Use: "getauth",
- Short: "Gets Auth token from beast server",
- Long: "Gets Auth Token from the beast server by completing the challenge from the server",
- PreRun: func(cmd *cobra.Command, args []string) {
- if Password == "" {
- fmt.Printf("Password not provided")
- os.Exit(1)
+ Short: "Gets an authentication token from the Beast server",
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if strings.TrimSpace(Username) == "" {
+ return fmt.Errorf("username is required")
}
-
- if Username == "" {
- fmt.Printf("Username not provided")
- os.Exit(1)
+ password := utils.PromptSecret("Enter Beast password")
+ if password == "" {
+ return fmt.Errorf("password is required")
}
- },
- Run: func(cmd *cobra.Command, args []string) {
- client.Authorize(Password, Host, Username)
+ response, err := client.Authorize(password, Host, Username, AuthCAFile)
+ if err != nil {
+ return err
+ }
+ fmt.Fprintf(cmd.OutOrStdout(), "Token\t: %s\nMessage\t: %s\n", response.Token, response.Message)
+ return nil
},
}
diff --git a/cmd/beast/backup.go b/cmd/beast/backup.go
index 59b1e2f7..060972ea 100644
--- a/cmd/beast/backup.go
+++ b/cmd/beast/backup.go
@@ -8,16 +8,28 @@ import (
var backupDatabase = &cobra.Command{
Use: "backup-database",
- Short: "Backups the existing database and remote/staging directories",
- Run: func(cmd *cobra.Command, args []string) {
- database.BackupDatabase()
+ Short: "Backs up the configured PostgreSQL database",
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ cleanup, err := initializeMaintenance(false)
+ if err != nil {
+ return err
+ }
+ defer cleanup()
+ return database.BackupDatabase()
},
}
var backupCache = &cobra.Command{
Use: "backup-cache",
- Short: "Backups the existing cache and remote/staging directories",
- Run: func(cmd *cobra.Command, args []string) {
- cache.BackupCache()
+ Short: "Backs up the configured Redis database",
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ cleanup, err := initializeMaintenance(true)
+ if err != nil {
+ return err
+ }
+ defer cleanup()
+ return cache.BackupCache()
},
}
diff --git a/cmd/beast/cache.go b/cmd/beast/cache.go
index c3b9086c..e667597b 100644
--- a/cmd/beast/cache.go
+++ b/cmd/beast/cache.go
@@ -1,30 +1,45 @@
package main
import (
+ "fmt"
+
"github.com/sdslabs/beastv4/core/cache"
- log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
)
var resetCacheCmd = &cobra.Command{
Use: "reset-cache",
- Short: "Backups the existing cache and cleans up old cache and remote/staging directories",
- Run: func(cmd *cobra.Command, args []string) {
- cache.BackupAndReset()
+ Short: "Backs up and resets the configured Redis database",
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if err := requireDestructiveConfirmation(); err != nil {
+ return err
+ }
+ cleanup, err := initializeMaintenance(true)
+ if err != nil {
+ return err
+ }
+ defer cleanup()
+ return cache.BackupAndReset()
},
}
var restoreCacheCmd = &cobra.Command{
Use: "restore-cache",
- Short: "Restores the cache, with the backed-up file",
- Run: func(cmd *cobra.Command, args []string) {
- if RestoreFile != "" {
- err := cache.RestoreCache(RestoreFile)
- if err != nil {
- log.Errorf("Error restoring cache from file %s: %v\n", RestoreFile, err)
- }
- } else {
- log.Fatalf("Restore file not specified.")
+ Short: "Restores the configured Redis database from a backup",
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if err := requireDestructiveConfirmation(); err != nil {
+ return err
+ }
+ if RestoreFile == "" {
+ return fmt.Errorf("restore file is required")
+ }
+ cleanup, err := initializeMaintenance(true)
+ if err != nil {
+ return err
}
+ defer cleanup()
+ return cache.RestoreCache(RestoreFile)
},
}
diff --git a/cmd/beast/challdetails.go b/cmd/beast/challdetails.go
index 2da2fbf5..63bead47 100644
--- a/cmd/beast/challdetails.go
+++ b/cmd/beast/challdetails.go
@@ -9,8 +9,13 @@ var challDetailsCmd = &cobra.Command{
Use: "chall-details",
Short: "Lists all challenge details",
Long: "Lists all challenge details | Flags available : --status , --tags. Status flag can take arguments : deployed / undeployed / queued. Tags flag can take multiple arguments seperated with ',' : (Ex : --tags=pwn,image,docker). Details are shown for challenges that have specified status and one of the specified tags.",
-
- Run: func(cmd *cobra.Command, args []string) {
- utils.ShowFilteredChallengesInfo(cmd, args)
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ cleanup, err := initializeCLIRuntime(false, false)
+ if err != nil {
+ return err
+ }
+ defer cleanup()
+ return utils.ShowFilteredChallengesInfo(cmd, args)
},
}
diff --git a/cmd/beast/challenge.go b/cmd/beast/challenge.go
index 057444e2..0af0eee8 100644
--- a/cmd/beast/challenge.go
+++ b/cmd/beast/challenge.go
@@ -1,11 +1,11 @@
package main
import (
- "os"
+ "errors"
+ "fmt"
"strings"
"github.com/sdslabs/beastv4/core"
- "github.com/sdslabs/beastv4/core/config"
"github.com/sdslabs/beastv4/core/manager"
"github.com/sdslabs/beastv4/core/utils"
wpool "github.com/sdslabs/beastv4/pkg/workerpool"
@@ -18,111 +18,87 @@ var challengeCmd = &cobra.Command{
Short: "Performs action to the challs",
Long: "Performs actions like : deploy, undeploy, redeploy, purge to the challs",
Args: cobra.MinimumNArgs(1),
- Run: func(cmd *cobra.Command, args []string) {
- config.InitConfig()
-
- // Since action is already verfied to exist it does not make sense to check
- // its existence here therefore we directly parse the action from the command.
+ RunE: func(cmd *cobra.Command, args []string) error {
action := args[0]
- noCache, _ := cmd.Flags().GetBool("no-cache")
- if noCache && action == core.MANAGE_ACTION_DEPLOY {
- config.NoCache = noCache
- } else if noCache {
- log.Errorf("no-cache flag is available only for \"deploy\" action")
- os.Exit(1)
+ if NoCache && action != core.MANAGE_ACTION_DEPLOY {
+ return fmt.Errorf("no-cache flag is available only for deploy")
}
if action == core.MANAGE_ACTION_SHOW {
-
- if AllChalls {
- errors := utils.ShowAllChallenges()
-
- if len(errors) > 0 {
- for _, err := range errors {
- log.Errorf("The following errors occurred: %s", err.Error())
- }
- os.Exit(1)
- }
-
- } else if Tag != "" {
- errors := utils.ShowTagRelatedChallenges(Tag)
-
- if len(errors) > 0 {
- for _, err := range errors {
- log.Errorf("The following errors occurred: %s", err.Error())
- }
- os.Exit(1)
- }
- } else {
- if len(args) == 1 {
- log.Errorf("Provide chall name")
- os.Exit(1)
- }
-
- errors := utils.ShowChallengeByName(args[1])
- if len(errors) > 0 {
- for _, err := range errors {
- log.Errorf("The following errors occurred: %s", err.Error())
- }
- os.Exit(1)
- }
-
+ cleanup, err := initializeCLIRuntime(false, false)
+ if err != nil {
+ return err
}
-
- return
+ defer cleanup()
+ return showChallenges(args)
}
challAction, ok := manager.ChallengeActionHandlers[action]
if !ok {
- log.Errorf("No action %s exists", action)
- os.Exit(1)
+ return fmt.Errorf("no challenge action %q exists", action)
}
+ cleanup, err := initializeCLIRuntime(true, true)
+ if err != nil {
+ return err
+ }
+ defer cleanup()
- // Handle local directory deployment separately.
if LocalDirectory != "" {
if action != core.MANAGE_ACTION_DEPLOY {
- log.Errorf("Only deploy action is available for the challenge with local directory")
- os.Exit(1)
+ return fmt.Errorf("local-directory is available only for deploy")
}
-
- manager.StartDeployPipeline(LocalDirectory, false, false, false)
- return
+ return manager.StartDeployPipeline(LocalDirectory, false, false, NoCache)
}
- completionChannel := make(chan bool)
-
- manager.Q = wpool.InitQueue(core.MAX_QUEUE_SIZE, completionChannel)
+ completion := make(chan bool, 1)
+ manager.Q = wpool.InitQueue(core.MAX_QUEUE_SIZE, completion)
manager.Q.StartWorkers(&manager.Worker{})
+ defer manager.Q.Stop()
if AllChalls {
- errstrings := manager.HandleAll(action, core.BEAST_LOCAL_SERVER)
- if len(errstrings) != 0 {
- log.Errorf("Following errors occurred : %s", strings.Join(errstrings, " || "))
- os.Exit(1)
- } else {
- log.Info("The action will be performed")
+ if failures := manager.HandleAll(action, core.BEAST_LOCAL_SERVER); len(failures) != 0 {
+ return fmt.Errorf("challenge actions failed: %s", strings.Join(failures, " || "))
}
} else if Tag != "" {
- errstrings := manager.HandleTagRelatedChallenges(action, Tag, core.BEAST_LOCAL_SERVER)
- if len(errstrings) != 0 {
- log.Errorf("Following errors occurred : %s", strings.Join(errstrings, " || "))
- os.Exit(1)
- } else {
- log.Info("The action will be performed")
+ if failures := manager.HandleTagRelatedChallenges(action, Tag, core.BEAST_LOCAL_SERVER); len(failures) != 0 {
+ return fmt.Errorf("challenge actions failed: %s", strings.Join(failures, " || "))
}
} else {
if len(args) == 1 {
- log.Errorf("Provide chall name")
- os.Exit(1)
+ return fmt.Errorf("challenge name is required")
}
- err := challAction(args[1])
- if err != nil {
- log.Errorf("The action was not performed due to error : %s", err.Error())
- os.Exit(1)
- } else {
- log.Info("The action will be performed")
+ if err := challAction(args[1]); err != nil {
+ return fmt.Errorf("perform %s on %s: %w", action, args[1], err)
}
}
- _ = <-completionChannel
+
+ <-completion
+ if failures := manager.Q.Errors(); len(failures) != 0 {
+ return errors.Join(failures...)
+ }
+ log.Info("Challenge action completed")
+ return nil
},
}
+
+func showChallenges(args []string) error {
+ var failures []error
+ switch {
+ case AllChalls:
+ failures = utils.ShowAllChallenges()
+ case Tag != "":
+ failures = utils.ShowTagRelatedChallenges(Tag)
+ case len(args) < 2:
+ return fmt.Errorf("challenge name is required")
+ default:
+ failures = utils.ShowChallengeByName(args[1])
+ }
+ if len(failures) == 0 {
+ return nil
+ }
+ messages := make([]string, 0, len(failures))
+ for _, err := range failures {
+ messages = append(messages, err.Error())
+ }
+ return fmt.Errorf("show challenges: %s", strings.Join(messages, "; "))
+}
diff --git a/cmd/beast/challenge_logs.go b/cmd/beast/challenge_logs.go
index f92012fc..d2f28a31 100644
--- a/cmd/beast/challenge_logs.go
+++ b/cmd/beast/challenge_logs.go
@@ -1,7 +1,6 @@
package main
import (
- "github.com/sdslabs/beastv4/core/config"
"github.com/sdslabs/beastv4/core/utils"
"github.com/spf13/cobra"
)
@@ -9,11 +8,14 @@ import (
var logsCmd = &cobra.Command{
Use: "logs CHALLNAME",
Short: "Provides live logs of a container",
- Args: cobra.MinimumNArgs(1),
-
- Run: func(cmd *cobra.Command, args []string) {
- config.InitConfig()
-
- utils.GetLogs(args[0], true)
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ cleanup, err := initializeCLIRuntime(false, false)
+ if err != nil {
+ return err
+ }
+ defer cleanup()
+ _, err = utils.GetLogs(args[0], true)
+ return err
},
}
diff --git a/cmd/beast/cmdref.go b/cmd/beast/cmdref.go
index a53959c2..d29c585b 100644
--- a/cmd/beast/cmdref.go
+++ b/cmd/beast/cmdref.go
@@ -1,7 +1,9 @@
package main
import (
- log "github.com/sirupsen/logrus"
+ "fmt"
+ "os"
+
"github.com/spf13/cobra"
"github.com/spf13/cobra/doc"
)
@@ -10,20 +12,16 @@ import (
var cmdRef = &cobra.Command{
Use: "cmdref [-r]",
Short: "Generate beast command reference",
- Run: func(cmd *cobra.Command, args []string) {
-
- if RefDirectory != "" {
- err := doc.GenMarkdownTree(rootCmd, RefDirectory)
- if err != nil {
- log.Fatal(err)
-
- }
- } else {
- err := doc.GenMarkdownTree(rootCmd, DEFAULT_CMDREF_DIRECTORY)
- if err != nil {
- log.Fatal(err)
-
- }
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ directory := RefDirectory
+ if directory == "" {
+ directory = DEFAULT_CMDREF_DIRECTORY
+ }
+ if err := os.MkdirAll(directory, 0750); err != nil {
+ return fmt.Errorf("create command reference directory: %w", err)
}
+ rootCmd.DisableAutoGenTag = true
+ return doc.GenMarkdownTree(rootCmd, directory)
},
}
diff --git a/cmd/beast/commands.go b/cmd/beast/commands.go
index 6ca5467d..96015250 100644
--- a/cmd/beast/commands.go
+++ b/cmd/beast/commands.go
@@ -10,28 +10,27 @@ import (
)
var (
- Verbose bool
- HealthProbe bool
- Port string
- DefaultAuthorPassword string
- Name string
- Host string
- Username string
- Email string
- Password string
- PublicKeyPath string
- SkipAuthorization bool
- AllChalls bool
- AutoDeploy bool
- PeriodicSync bool
- Tag string
- LocalDirectory string
- DeleteEntry bool
- RefDirectory string
- Status string
- Tags string
- NoCache bool
- RestoreFile string
+ Verbose bool
+ HealthProbe bool
+ Port string
+ DefaultAuthorPasswordFile string
+ Name string
+ Host string
+ Username string
+ Email string
+ AuthCAFile string
+ AllChalls bool
+ AutoDeploy bool
+ PeriodicSync bool
+ Tag string
+ LocalDirectory string
+ DeleteEntry bool
+ RefDirectory string
+ Status string
+ Tags string
+ NoCache bool
+ RestoreFile string
+ ConfirmDestructive bool
)
// Root command `beast` all commands are either a flag to this command
@@ -47,12 +46,6 @@ var rootCmd = &cobra.Command{
debug.Disable()
}
- if SkipAuthorization {
- config.SkipAuthorization = true
- } else {
- config.SkipAuthorization = false
- }
-
config.NoCache = NoCache
},
Run: func(cmd *cobra.Command, args []string) {
@@ -77,34 +70,30 @@ func init() {
rootCmd.PersistentFlags().BoolVarP(&Verbose, "verbose", "v", false, "Print extra information in stdout1")
runCmd.PersistentFlags().StringVarP(&Port, "port", "p", "", "Port to run the beast server on.")
- runCmd.PersistentFlags().StringVarP(&DefaultAuthorPassword, "defaultauthorpassword", "q", "", "Default password for creating author, users are not created if value is empty string")
+ runCmd.PersistentFlags().StringVar(&DefaultAuthorPasswordFile, "default-author-password-file", "", "0600 file containing the password used to create missing authors")
runCmd.PersistentFlags().BoolVarP(&AutoDeploy, "auto-deploy", "a", false, "Auto deploy all challenges from remote on server start.")
runCmd.PersistentFlags().BoolVarP(&HealthProbe, "health-probe", "k", false, "Run health check service for beast deployed challenges")
runCmd.PersistentFlags().BoolVarP(&PeriodicSync, "periodic-sync", "s", false, "Periodically sync remote with beast and auto update challenges.")
- runCmd.PersistentFlags().BoolVarP(&SkipAuthorization, "noauth", "n", false, "Skip Authorization")
runCmd.PersistentFlags().BoolVarP(&NoCache, "no-cache", "c", false, "Build image of challenge without using cache")
getAuthCmd.PersistentFlags().StringVarP(&Username, "username", "u", "", "Username")
- getAuthCmd.PersistentFlags().StringVarP(&Password, "password", "p", "", "Password")
- getAuthCmd.PersistentFlags().StringVarP(&Host, "host", "H", "http://localhost:5005/", "Hostname or IP along with port where beast is hosted")
+ getAuthCmd.PersistentFlags().StringVarP(&Host, "host", "H", "https://localhost:5005/", "HTTPS URL where Beast is hosted")
+ getAuthCmd.PersistentFlags().StringVar(&AuthCAFile, "ca-file", "", "CA certificate used to verify the Beast server")
createAuthorCmd.PersistentFlags().StringVarP(&Name, "name", "", "", "Name of the new author")
createAuthorCmd.PersistentFlags().StringVarP(&Username, "username", "", "", "Username of the new author")
- createAuthorCmd.PersistentFlags().StringVarP(&Password, "password", "", "", "Password of the author")
createAuthorCmd.PersistentFlags().StringVarP(&Email, "email", "", "", "Email of the new author")
- createAuthorCmd.PersistentFlags().StringVarP(&PublicKeyPath, "publickey", "", "", "Public key file representing new author")
createAdminCmd.PersistentFlags().StringVarP(&Name, "name", "", "", "Name of the new admin")
createAdminCmd.PersistentFlags().StringVarP(&Username, "username", "", "", "Username of the new admin")
- createAdminCmd.PersistentFlags().StringVarP(&Password, "password", "", "", "Password of the admin")
createAdminCmd.PersistentFlags().StringVarP(&Email, "email", "", "", "Email of the new admin")
- createAdminCmd.PersistentFlags().StringVarP(&PublicKeyPath, "publickey", "", "", "Public key file representing new admin")
challengeCmd.PersistentFlags().BoolVarP(&AllChalls, "all", "a", false, "Performs action to all challs")
challengeCmd.PersistentFlags().StringVarP(&Tag, "tag", "t", "", "Performs action to the tag provided")
challengeCmd.PersistentFlags().StringVarP(&LocalDirectory, "local-directory", "l", "", "Deploys challenge from local directory")
challengeCmd.PersistentFlags().BoolVarP(&DeleteEntry, "delete-entry", "d", false, "Deletes db entry related to this challenge")
challengeCmd.PersistentFlags().BoolVarP(&NoCache, "no-cache", "c", false, "Build image of challenge without using cache")
+ verifyCmd.Flags().StringVarP(&LocalDirectory, "local-directory", "l", "", "Validate a challenge from a local directory")
cmdRef.PersistentFlags().StringVarP(&RefDirectory, "reference-directory", "r", "", "Generate beast command reference files in reference directory")
@@ -112,9 +101,12 @@ func init() {
challDetailsCmd.PersistentFlags().StringVarP(&Tags, "tags", "t", "", "Filter by tagname : pwn / web / image / docker")
restoreDatabaseCmd.PersistentFlags().StringVarP(&RestoreFile, "restore-file", "r", "", "Backup file to be used for restoration.")
-
+ resetDatabaseCmd.Flags().BoolVar(&ConfirmDestructive, "yes", false, "Confirm destructive database reset")
+ restoreDatabaseCmd.Flags().BoolVar(&ConfirmDestructive, "yes", false, "Confirm destructive database restore")
restoreCacheCmd.PersistentFlags().StringVarP(&RestoreFile, "restore-file", "r", "", "Restore file to be used for restoration.")
+ resetCacheCmd.Flags().BoolVar(&ConfirmDestructive, "yes", false, "Confirm destructive cache reset")
+ restoreCacheCmd.Flags().BoolVar(&ConfirmDestructive, "yes", false, "Confirm destructive cache restore")
rootCmd.AddCommand(versionCmd)
rootCmd.AddCommand(initCmd)
@@ -127,7 +119,6 @@ func init() {
rootCmd.AddCommand(healthProbeCmd)
rootCmd.AddCommand(verifyCmd)
rootCmd.AddCommand(challengeCmd)
- rootCmd.AddCommand(disableUserSSH)
rootCmd.AddCommand(cmdRef)
rootCmd.AddCommand(generateTemplateCmd)
rootCmd.AddCommand(challDetailsCmd)
diff --git a/cmd/beast/config.go b/cmd/beast/config.go
index 2b82f59b..2aac7352 100644
--- a/cmd/beast/config.go
+++ b/cmd/beast/config.go
@@ -1,113 +1,35 @@
package main
import (
- "errors"
+ "bytes"
+ "crypto/rand"
+ "encoding/base64"
"fmt"
+ "os"
+ "path/filepath"
+ "strconv"
+ "time"
+
"github.com/BurntSushi/toml"
"github.com/sdslabs/beastv4/core"
"github.com/sdslabs/beastv4/core/config"
"github.com/sdslabs/beastv4/utils"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
- "io"
- "net/http"
- "os"
- "path/filepath"
- "strconv"
- "time"
)
var (
- MINIMUM_MEMORY_LIMIT int64 = (1 << 23) /* a little over 6MB */
- AUTHORIZED_KEYS_FILE string = filepath.Join(core.BEAST_GLOBAL_DIR, core.DEFAULT_AUTH_KEYS_FILE)
+ MINIMUM_MEMORY_LIMIT int64 = (1 << 23) /* a little over 6MB */
BEAST_GLOBAL_CONFIG string = filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_CONFIG_FILE_NAME)
-
- BEAST_EXAMPLE_DIRECTORY string = filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_EXAMPLE_DIR)
- BEAST_EXAMPLE_CONFIG string = filepath.Join(BEAST_EXAMPLE_DIRECTORY, core.BEAST_EX_CONFIG_FILE_NAME)
)
-func copySSHKey() error {
- location, err := utils.PromptPublicKeyFile()
- if err != nil {
- return err
- }
-
- if location == "" {
- log.Warnln("No public key file selected... aborting")
- return nil
- }
-
- publicKeyFile, err := os.Open(location)
- if err != nil {
- return err
- }
- defer publicKeyFile.Close()
-
- authorizedKeyFile, err := os.OpenFile(AUTHORIZED_KEYS_FILE, os.O_APPEND|os.O_WRONLY, 0600)
- if err != nil {
- return err
- }
- defer authorizedKeyFile.Close()
-
- _, err = io.Copy(authorizedKeyFile, publicKeyFile)
- if err != nil {
- return err
- }
-
- _, err = authorizedKeyFile.WriteString("\n\n")
- if err != nil {
- return err
+func generateConfigSecret(size int) (string, error) {
+ secret := make([]byte, size)
+ if _, err := rand.Read(secret); err != nil {
+ return "", err
}
-
- log.Infoln(fmt.Sprintf("Added ssh key at %s", location))
- return nil
-}
-
-func initAuthorizedKeysFile() error {
- log.Infoln("Defaulting Authorized keys file:", AUTHORIZED_KEYS_FILE, "... can be changed later")
-
- err := os.WriteFile(AUTHORIZED_KEYS_FILE, []byte{}, 0666)
- if err != nil {
- return err
- }
-
- for utils.PromptBinary("Would you like to add a public key to the authorized_keys file?") {
- err = copySSHKey()
- if err != nil {
- return err
- }
- }
-
- return nil
-}
-
-func downloadExampleBeastConfig() error {
- log.Println("Downloading example config file from GitHub...")
-
- response, err := http.Get("https://raw.githubusercontent.com/sdslabs/beast/master/_examples/example.config.toml")
- if err != nil {
- return err
- }
- defer response.Body.Close()
-
- if response.StatusCode != http.StatusOK {
- return errors.New("error while downloading: " + response.Status)
- }
-
- err = os.MkdirAll(BEAST_EXAMPLE_DIRECTORY, 0755)
- if err != nil {
- return err
- }
-
- exampleConfig, err := os.Create(BEAST_EXAMPLE_CONFIG)
- if err != nil {
- return err
- }
- defer exampleConfig.Close()
-
- _, err = io.Copy(exampleConfig, response.Body)
- return err
+ return base64.RawURLEncoding.EncodeToString(secret), nil
}
func promptServerDetails(configuration *config.BeastConfig) {
@@ -118,8 +40,15 @@ func promptServerDetails(configuration *config.BeastConfig) {
if server.Host == "" {
server.Host = core.LOCALHOST
}
- server.Username = utils.PromptString("Enter Username")
- server.SSHKeyPath = utils.PromptString("Enter SSH Key Path")
+ if server.Host != core.LOCALHOST && server.Host != core.LOCALHOST_IP {
+ server.Username = utils.PromptString("Enter SSH username")
+ server.SSHKeyPath = utils.PromptString("Enter SSH private key path (must be mode 0600)")
+ server.KnownHostsFile = utils.PromptString("Enter known_hosts path (leave empty for $HOME/.ssh/known_hosts)")
+ }
+ server.PortRange = utils.PromptString("Enter allocatable host port range (leave empty for 10000:20000)")
+ if server.PortRange == "" {
+ server.PortRange = "10000:20000"
+ }
server.Active = utils.PromptBinary("Enable this server?")
configuration.AvailableServers[server.Host] = server
@@ -127,7 +56,7 @@ func promptServerDetails(configuration *config.BeastConfig) {
}
func promptResourceLimits(configuration *config.BeastConfig) {
- configuration.CPUShares = utils.PromptInt64("Default CPU Share (must be over 6MB):", core.DEFAULT_CPU_SHARE)
+ configuration.CPUShares = utils.PromptInt64("Default CPU shares", core.DEFAULT_CPU_SHARE)
configuration.CPUsLimit = utils.PromptFloat32("Default CPU Limit", core.DEFAULT_CPU_LIMIT)
configuration.PidsLimit = utils.PromptInt64("Default PIDs Limit:", core.DEFAULT_PIDS_LIMIT)
configuration.Memory = utils.PromptInt64("Default Memory Limit:", core.DEFAULT_MEMORY_LIMIT)
@@ -146,7 +75,7 @@ func promptRemoteRepository(configuration *config.BeastConfig) {
remote.Active = utils.PromptBinary("Enable this repository?")
remote.RemoteName = utils.PromptString("Remote Repository Name")
remote.Branch = utils.PromptString("Remote Repository Branch")
- remote.Secret = utils.PromptSecret("Remote Repository SSH Key")
+ remote.Secret = utils.PromptString("Path to remote repository SSH private key")
err := remote.ValidateGitConfig()
if err != nil {
@@ -160,6 +89,9 @@ func promptRemoteRepository(configuration *config.BeastConfig) {
}
func promptCompetitionDetails(configuration *config.BeastConfig) {
+ if !utils.PromptBinary("Configure competition metadata?") {
+ return
+ }
configuration.CompetitionInfo.Name = utils.PromptString("Enter Competition Name")
configuration.CompetitionInfo.About = utils.PromptString("Enter Competition About Text")
configuration.CompetitionInfo.Prizes = utils.PromptString("Enter Competition Prizes Text")
@@ -196,15 +128,20 @@ func promptNotificationWebhooks(configuration *config.BeastConfig) {
}
}
-func promptCacheConnectionDetails(configuration *config.BeastConfig) {
+func promptCacheConnectionDetails(configuration *config.BeastConfig) error {
configuration.RedisConf.User = utils.PromptString("Enter Redis User Name (this user will be created if does not exist)... leaving it empty will default it to beast")
if configuration.RedisConf.User == "" {
configuration.RedisConf.User = "beast"
}
- configuration.RedisConf.Password = utils.PromptSecret(fmt.Sprintf("Enter Redis User %s Password... leaving it empty will default it to beast", configuration.RedisConf.User))
+ configuration.RedisConf.Password = utils.PromptSecret(fmt.Sprintf("Enter Redis user %s password (leave empty to generate one)", configuration.RedisConf.User))
if configuration.RedisConf.Password == "" {
- configuration.RedisConf.Password = "beast"
+ password, err := generateConfigSecret(32)
+ if err != nil {
+ return err
+ }
+ configuration.RedisConf.Password = password
+ log.Info("Generated a Redis password in the private Beast configuration")
}
configuration.RedisConf.Host = utils.PromptString("Enter Redis Host Name, leave empty for localhost")
@@ -213,12 +150,18 @@ func promptCacheConnectionDetails(configuration *config.BeastConfig) {
}
configuration.RedisConf.Port = strconv.FormatInt(utils.PromptInt64("Enter Redis Port", 6379), 10)
+ configuration.RedisConf.TLS = utils.PromptBinary("Use TLS for Redis?")
+ if configuration.RedisConf.TLS {
+ configuration.RedisConf.CAFile = utils.PromptString("Redis CA certificate path (empty uses system roots)")
+ configuration.RedisConf.ServerName = utils.PromptString("Redis TLS server name (empty uses host)")
+ }
log.Infoln("Setting Redis DB to 0...")
configuration.RedisConf.Db = 0
+ return nil
}
-func promptDatabaseConnectionDetails(configuration *config.BeastConfig) {
+func promptDatabaseConnectionDetails(configuration *config.BeastConfig) error {
configuration.PsqlConf.User = utils.PromptString("Enter Postgres User Name (this user will be created if does not exist)... leaving it empty will default it to beast")
if configuration.PsqlConf.User == "" {
configuration.PsqlConf.User = "beast"
@@ -229,9 +172,14 @@ func promptDatabaseConnectionDetails(configuration *config.BeastConfig) {
configuration.PsqlConf.Dbname = "beast"
}
- configuration.PsqlConf.Password = utils.PromptSecret(fmt.Sprintf("Enter Postgres User %s Password... leaving it empty will default it to beast", configuration.PsqlConf.User))
+ configuration.PsqlConf.Password = utils.PromptSecret(fmt.Sprintf("Enter Postgres user %s password (leave empty to generate one)", configuration.PsqlConf.User))
if configuration.PsqlConf.Password == "" {
- configuration.PsqlConf.Password = "beast"
+ password, err := generateConfigSecret(32)
+ if err != nil {
+ return err
+ }
+ configuration.PsqlConf.Password = password
+ log.Info("Generated a PostgreSQL password in the private Beast configuration")
}
configuration.PsqlConf.Host = utils.PromptString("Enter Postgres Host Name, leave empty for localhost")
@@ -244,64 +192,89 @@ func promptDatabaseConnectionDetails(configuration *config.BeastConfig) {
"allow",
"prefer",
"require",
+ "verify-ca",
+ "verify-full",
})
+ if configuration.PsqlConf.SslMode == "verify-ca" || configuration.PsqlConf.SslMode == "verify-full" {
+ configuration.PsqlConf.SSLRootCert = utils.PromptString("Postgres root CA certificate path")
+ }
+ return nil
}
-func promptBeastConfiguration(configuration *config.BeastConfig) {
+func promptBeastConfiguration(configuration *config.BeastConfig) error {
promptServerDetails(configuration)
promptResourceLimits(configuration)
promptRemoteRepository(configuration)
promptCompetitionDetails(configuration)
promptNotificationWebhooks(configuration)
- promptCacheConnectionDetails(configuration)
- promptDatabaseConnectionDetails(configuration)
+ if err := promptCacheConnectionDetails(configuration); err != nil {
+ return err
+ }
+ return promptDatabaseConnectionDetails(configuration)
}
func tryCopyExampleConfig() error {
- var configuration config.BeastConfig
-
- if _, err := os.Stat(BEAST_EXAMPLE_CONFIG); os.IsNotExist(err) {
- log.Println("No example config file found...")
-
- if err = downloadExampleBeastConfig(); err != nil {
- return err
- }
- }
-
- log.Println("Reading example config file at", BEAST_EXAMPLE_CONFIG)
-
- data, err := os.ReadFile(BEAST_EXAMPLE_CONFIG)
+ jwtSecret, err := generateConfigSecret(48)
if err != nil {
return err
}
-
- if _, err = toml.Decode(string(data), &configuration); err != nil {
+ configuration := config.BeastConfig{
+ AllowedBaseImages: []string{"ubuntu:24.04", "debian:bookworm"},
+ AvailableServers: map[string]config.AvailableServer{
+ core.LOCALHOST: {Host: core.LOCALHOST, Active: true, PortRange: "10000:20000"},
+ },
+ JWTSecret: jwtSecret,
+ TickerFrequency: core.DEFAULT_TICKER_FREQUENCY,
+ CPUShares: core.DEFAULT_CPU_SHARE,
+ CPUsLimit: core.DEFAULT_CPU_LIMIT,
+ Memory: core.DEFAULT_MEMORY_LIMIT,
+ PidsLimit: core.DEFAULT_PIDS_LIMIT,
+ PsqlConf: config.PsqlConfig{User: "beast", Dbname: "beast", Host: core.LOCALHOST, Port: "5432", SslMode: "disable"},
+ RedisConf: config.RedisConfig{User: "beast", Host: core.LOCALHOST, Port: "6379"},
+ InstanceConfig: config.InstanceConfig{DefaultExpiration: 300, MaxExtension: 600, MaxInstancesPerUser: 3},
+ ServerConfig: config.ServerConfig{
+ TLSCertFile: filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_SECRETS_DIR, "tls.crt"),
+ TLSKeyFile: filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_SECRETS_DIR, "tls.key"),
+ },
+ }
+ if err := promptBeastConfiguration(&configuration); err != nil {
return err
}
+ if err := ensureLocalTLSCertificate(); err != nil {
+ return err
+ }
+ if err := configuration.ValidateConfig(); err != nil {
+ return fmt.Errorf("validate generated configuration: %w", err)
+ }
- promptBeastConfiguration(&configuration)
-
- file, err := os.Create(BEAST_GLOBAL_CONFIG)
+ var encoded bytes.Buffer
+ if err := toml.NewEncoder(&encoded).Encode(configuration); err != nil {
+ return err
+ }
+ file, err := os.OpenFile(BEAST_GLOBAL_CONFIG, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
if err != nil {
return err
}
- defer file.Close()
-
- encoder := toml.NewEncoder(file)
- if err = encoder.Encode(configuration); err != nil {
+ if err := file.Chmod(0600); err != nil {
+ file.Close()
return err
}
-
- return nil
-}
-
-func initBeastConfig() error {
- if err := initAuthorizedKeysFile(); err != nil {
+ if _, err := file.Write(encoded.Bytes()); err != nil {
+ file.Close()
+ _ = os.Remove(BEAST_GLOBAL_CONFIG)
return err
}
+ return file.Close()
+}
- if _, err := os.Stat(BEAST_GLOBAL_CONFIG); os.IsNotExist(err) {
+func initBeastConfig() error {
+ if _, err := os.Lstat(BEAST_GLOBAL_CONFIG); os.IsNotExist(err) {
+ if err := initDirectories(); err != nil {
+ return err
+ }
return tryCopyExampleConfig()
+ } else if err != nil {
+ return err
}
log.Infoln("Found global config file:", BEAST_GLOBAL_CONFIG)
@@ -311,17 +284,15 @@ func initBeastConfig() error {
var configCmd = &cobra.Command{
Use: "config",
Short: "Run interactive beast configuration setup",
- Long: "Creates the default Authorized Keys File and Global Beast Config file while prompting the user interactively whenever needed.",
+ Long: "Creates the global Beast config file while prompting the user interactively whenever needed.",
- Run: func(cmd *cobra.Command, args []string) {
- err := initBeastConfig()
-
- if err != nil {
- log.Errorln(err.Error())
- log.Errorln("Failed to create global beast config file... fix the above errors and try again")
- return
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if err := initBeastConfig(); err != nil {
+ return fmt.Errorf("initialize Beast configuration: %w", err)
}
log.Infoln(fmt.Sprintf("Beast global config file initiliazed at %s", BEAST_GLOBAL_CONFIG))
+ return nil
},
}
diff --git a/cmd/beast/config_test.go b/cmd/beast/config_test.go
new file mode 100644
index 00000000..c729cac3
--- /dev/null
+++ b/cmd/beast/config_test.go
@@ -0,0 +1,17 @@
+package main
+
+import "testing"
+
+func TestGenerateConfigSecret(t *testing.T) {
+ first, err := generateConfigSecret(32)
+ if err != nil {
+ t.Fatal(err)
+ }
+ second, err := generateConfigSecret(32)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(first) < 32 || first == second {
+ t.Fatalf("generated secrets are not sufficiently distinct: %q %q", first, second)
+ }
+}
diff --git a/cmd/beast/create_admin_author.go b/cmd/beast/create_admin_author.go
index 453e5df6..e30ed97c 100644
--- a/cmd/beast/create_admin_author.go
+++ b/cmd/beast/create_admin_author.go
@@ -2,87 +2,95 @@ package main
import (
"fmt"
- "os"
+ "net/mail"
+ "regexp"
+ "strings"
"github.com/sdslabs/beastv4/core"
"github.com/sdslabs/beastv4/core/config"
- "github.com/sdslabs/beastv4/core/utils"
+ "github.com/sdslabs/beastv4/core/database"
+ coreUtils "github.com/sdslabs/beastv4/core/utils"
"github.com/sdslabs/beastv4/pkg/auth"
+ "github.com/sdslabs/beastv4/utils"
"github.com/spf13/cobra"
)
-func createAuthorAdminPrereq() {
- config.InitConfig()
+var managerUsernamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_.-]{2,31}$`)
- auth.Init(core.ITERATIONS, core.HASH_LENGTH, core.TIMEPERIOD, core.ISSUER, config.Cfg.JWTSecret, []string{core.USER_ROLES["author"]}, []string{core.USER_ROLES["admin"]}, []string{core.USER_ROLES["contestant"]})
-}
+func createAuthorAdminPrereq() error {
+ if err := config.InitConfig(); err != nil {
+ return err
+ }
-var createAuthorCmd = &cobra.Command{
- Use: "create-author",
- Short: "Creates new author",
- Long: "Creates new author using command line arguments",
- PreRun: func(cmd *cobra.Command, args []string) {
- if Name == "" {
- fmt.Printf("Name of Author not provided")
- os.Exit(1)
- }
- if Username == "" {
- fmt.Printf("Username of Author not provided")
- os.Exit(1)
- }
+ auth.Init(core.ITERATIONS, core.HASH_LENGTH, core.TIMEPERIOD, core.ISSUER, config.Cfg.JWTSecret, []string{core.USER_ROLES["author"], core.USER_ROLES["maintainer"]}, []string{core.USER_ROLES["admin"]}, []string{core.USER_ROLES["contestant"]})
+ return database.Init()
+}
- if Email == "" {
- fmt.Printf("Email not provided")
- os.Exit(1)
- }
+func validateManagerArguments(role string) error {
+ if strings.TrimSpace(Name) == "" {
+ return fmt.Errorf("name of %s is required", role)
+ }
+ if len(Name) > 128 {
+ return fmt.Errorf("name must not exceed 128 bytes")
+ }
+ if !managerUsernamePattern.MatchString(Username) {
+ return fmt.Errorf("username must match %s", managerUsernamePattern.String())
+ }
+ mailbox, err := mail.ParseAddress(Email)
+ if err != nil || mailbox.Address != Email {
+ return fmt.Errorf("email must be a canonical mailbox address")
+ }
+ return nil
+}
- if PublicKeyPath == "" {
- fmt.Printf("Public Key Path not provided")
- }
+func promptNewPassword() (string, error) {
+ password := utils.PromptSecret("Enter password")
+ confirmation := utils.PromptSecret("Confirm password")
+ if password != confirmation {
+ return "", fmt.Errorf("password confirmation does not match")
+ }
+ if len(password) < 12 || len(password) > 128 || strings.TrimSpace(password) == "" {
+ return "", fmt.Errorf("password must contain between 12 and 128 non-whitespace bytes")
+ }
+ return password, nil
+}
- if Password == "" {
- fmt.Printf("Password not provided")
- os.Exit(1)
- }
- },
+func createManager(role string) error {
+ if err := validateManagerArguments(role); err != nil {
+ return err
+ }
+ password, err := promptNewPassword()
+ if err != nil {
+ return err
+ }
+ if err := createAuthorAdminPrereq(); err != nil {
+ return fmt.Errorf("initialize configuration: %w", err)
+ }
+ sqlDB, err := database.Db.DB()
+ if err != nil {
+ return fmt.Errorf("access database connection: %w", err)
+ }
+ defer sqlDB.Close()
+ if err := coreUtils.CreateAdminOrAuthor(Name, Username, Email, password, role); err != nil {
+ return fmt.Errorf("create %s: %w", role, err)
+ }
+ return nil
+}
- Run: func(cmd *cobra.Command, args []string) {
- createAuthorAdminPrereq()
- utils.CreateAdminOrAuthor(Name, Username, Email, PublicKeyPath, Password, "author")
+var createAuthorCmd = &cobra.Command{
+ Use: "create-author",
+ Short: "Creates a new author",
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ return createManager("author")
},
}
var createAdminCmd = &cobra.Command{
Use: "create-admin",
- Short: "Creates new admin",
- Long: "Creates new admin using command line arguments",
- PreRun: func(cmd *cobra.Command, args []string) {
- if Name == "" {
- fmt.Printf("Name of Admin not provided")
- os.Exit(1)
- }
- if Username == "" {
- fmt.Printf("Username of Admin not provided")
- os.Exit(1)
- }
-
- if Email == "" {
- fmt.Printf("Email not provided")
- os.Exit(1)
- }
-
- if PublicKeyPath == "" {
- fmt.Printf("Public Key Path not provided")
- }
-
- if Password == "" {
- fmt.Printf("Password not provided")
- os.Exit(1)
- }
- },
-
- Run: func(cmd *cobra.Command, args []string) {
- createAuthorAdminPrereq()
- utils.CreateAdminOrAuthor(Name, Username, Email, PublicKeyPath, Password, "admin")
+ Short: "Creates a new admin",
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ return createManager("admin")
},
}
diff --git a/cmd/beast/database.go b/cmd/beast/database.go
index cc5a2122..31e384b6 100644
--- a/cmd/beast/database.go
+++ b/cmd/beast/database.go
@@ -1,30 +1,45 @@
package main
import (
+ "fmt"
+
"github.com/sdslabs/beastv4/core/database"
- log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
)
var resetDatabaseCmd = &cobra.Command{
Use: "reset-database",
- Short: "Backups the existing database and cleans up old db and remote/staging directories",
- Run: func(cmd *cobra.Command, args []string) {
- database.BackupAndReset()
+ Short: "Backs up and resets the configured PostgreSQL database",
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if err := requireDestructiveConfirmation(); err != nil {
+ return err
+ }
+ cleanup, err := initializeMaintenance(false)
+ if err != nil {
+ return err
+ }
+ defer cleanup()
+ return database.BackupAndReset()
},
}
var restoreDatabaseCmd = &cobra.Command{
Use: "restore-database",
- Short: "Restores the database, with the backed-up file",
- Run: func(cmd *cobra.Command, args []string) {
- if RestoreFile != "" {
- err := database.RestoreDatabase(RestoreFile)
- if err != nil {
- log.Errorf("Error restoring database from file %s: %v\n", RestoreFile, err)
- }
- } else {
- log.Fatalf("Restore file not specified.")
+ Short: "Restores the configured PostgreSQL database from a backup",
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if err := requireDestructiveConfirmation(); err != nil {
+ return err
+ }
+ if RestoreFile == "" {
+ return fmt.Errorf("restore file is required")
+ }
+ cleanup, err := initializeMaintenance(false)
+ if err != nil {
+ return err
}
+ defer cleanup()
+ return database.RestoreDatabase(RestoreFile)
},
}
diff --git a/cmd/beast/disable_user_ssh.go b/cmd/beast/disable_user_ssh.go
deleted file mode 100644
index 5ffc18b2..00000000
--- a/cmd/beast/disable_user_ssh.go
+++ /dev/null
@@ -1,16 +0,0 @@
-package main
-
-import (
- "github.com/sdslabs/beastv4/core/auth"
- "github.com/sdslabs/beastv4/core/config"
- "github.com/spf13/cobra"
-)
-
-var disableUserSSH = &cobra.Command{
- Use: "disable-author-ssh",
- Short: "Disables current authors to ssh into the containers",
- Run: func(cmd *cobra.Command, args []string) {
- config.InitConfig()
- auth.DisableUserSSH()
- },
-}
diff --git a/cmd/beast/generate_template.go b/cmd/beast/generate_template.go
index 24022974..eb317299 100644
--- a/cmd/beast/generate_template.go
+++ b/cmd/beast/generate_template.go
@@ -2,118 +2,75 @@ package main
import (
"bytes"
+ "fmt"
"os"
"path/filepath"
"text/template"
"github.com/sdslabs/beastv4/core"
- "github.com/sdslabs/beastv4/core/config"
+ challengeConfig "github.com/sdslabs/beastv4/core/config"
tools "github.com/sdslabs/beastv4/templates"
- log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
)
var generateTemplateCmd = &cobra.Command{
Use: "new",
- Short: "generate config file",
- Long: "generate basic challenge config and public directory ",
-
- Run: func(cmd *cobra.Command, args []string) {
- log.Debug("Generating Config Template")
-
- path, err := os.Getwd()
- if err != nil {
- log.Errorf("Error while finding directory's path :: %s : using empty string instead", err)
- }
-
- challName := filepath.Base(path)
- if challName == "." {
- challName = ""
- }
-
- var config config.BeastChallengeConfig
- config.PopulateDefaultValues()
-
- var configfile bytes.Buffer
- log.Debugf("Preparing Config template")
- configfileTemplate, err := template.New("configfile").Parse(tools.CHALLENGE_CONFIG_FILE_TEMPLATE)
- if err != nil {
- log.Errorf("Error while parsing configfile template :: %s", err)
- return
- }
-
- log.Debugf("Executing dockerfile template with challenge config")
- err = configfileTemplate.Execute(&configfile, config)
- if err != nil {
- log.Errorf("Error while executing configfile template :: %s", err)
- return
- }
-
- err = createFile()
- if err != nil {
- log.Errorf("Error while creating beast.toml :: %s", err)
- return
- }
-
- var file, erro = os.OpenFile(core.CHALLENGE_CONFIG_FILE_NAME, os.O_RDWR, 0644)
- if erro != nil {
- log.Fatal(erro)
- }
-
- _, err = file.WriteString(configfile.String())
- if err != nil {
- log.Errorf("Error while writing beast.toml :: %s", err)
- }
-
- defer file.Close()
- log.Debugf("beast.toml generated for the challenge")
-
- err = createPublicDir()
+ Short: "Generate a challenge configuration and public directory",
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ directory, err := os.Getwd()
if err != nil {
- log.Errorf("Error while creating public directory")
+ return fmt.Errorf("resolve working directory: %w", err)
}
-
+ return generateChallengeTemplate(directory)
},
}
-func createFile() error {
- // check if file exists
- _, err := os.Stat(core.CHALLENGE_CONFIG_FILE_NAME)
-
- // create file if not exists
- if os.IsNotExist(err) {
-
- file, err := os.Create(core.CHALLENGE_CONFIG_FILE_NAME)
- if err != nil {
- return err
+func generateChallengeTemplate(directory string) error {
+ configPath := filepath.Join(directory, core.CHALLENGE_CONFIG_FILE_NAME)
+ publicPath := filepath.Join(directory, core.PUBLIC)
+ for _, path := range []string{configPath, publicPath} {
+ if _, err := os.Lstat(path); err == nil {
+ return fmt.Errorf("refusing to overwrite existing path: %s", path)
+ } else if !os.IsNotExist(err) {
+ return fmt.Errorf("inspect output path %s: %w", path, err)
}
- defer file.Close()
- return nil
-
- } else if err != nil {
- return err
}
- log.Errorf("%s already exists", core.CHALLENGE_CONFIG_FILE_NAME)
-
- return nil
-}
-
-func createPublicDir() error {
- // check if public directory exists
- _, err := os.Stat(core.PUBLIC)
+ var configuration challengeConfig.BeastChallengeConfig
+ configuration.PopulateDefaultValues()
+ parsed, err := template.New("configfile").Parse(tools.CHALLENGE_CONFIG_FILE_TEMPLATE)
+ if err != nil {
+ return fmt.Errorf("parse challenge template: %w", err)
+ }
+ var contents bytes.Buffer
+ if err := parsed.Execute(&contents, configuration); err != nil {
+ return fmt.Errorf("render challenge template: %w", err)
+ }
- // create public directory if not exists
- if os.IsNotExist(err) {
- err = os.MkdirAll(core.PUBLIC, 0755)
- if err != nil {
- return err
+ file, err := os.OpenFile(configPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
+ if err != nil {
+ return fmt.Errorf("create %s: %w", configPath, err)
+ }
+ keepConfig := false
+ defer func() {
+ _ = file.Close()
+ if !keepConfig {
+ _ = os.Remove(configPath)
}
- return nil
- } else if err != nil {
- return err
+ }()
+ if _, err := file.Write(contents.Bytes()); err != nil {
+ return fmt.Errorf("write %s: %w", configPath, err)
}
-
- log.Errorf("%s directory already exists", core.PUBLIC)
+ if err := file.Sync(); err != nil {
+ return fmt.Errorf("sync %s: %w", configPath, err)
+ }
+ if err := file.Close(); err != nil {
+ return fmt.Errorf("close %s: %w", configPath, err)
+ }
+ if err := os.Mkdir(publicPath, 0750); err != nil {
+ return fmt.Errorf("create public directory: %w", err)
+ }
+ keepConfig = true
return nil
}
diff --git a/cmd/beast/generate_template_test.go b/cmd/beast/generate_template_test.go
new file mode 100644
index 00000000..a8f0efd0
--- /dev/null
+++ b/cmd/beast/generate_template_test.go
@@ -0,0 +1,49 @@
+package main
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/sdslabs/beastv4/core"
+ challengeConfig "github.com/sdslabs/beastv4/core/config"
+)
+
+func TestGenerateChallengeTemplateRefusesOverwrite(t *testing.T) {
+ directory := t.TempDir()
+ configPath := filepath.Join(directory, core.CHALLENGE_CONFIG_FILE_NAME)
+ if err := os.WriteFile(configPath, []byte("keep"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if err := generateChallengeTemplate(directory); err == nil {
+ t.Fatal("expected existing template error")
+ }
+ contents, err := os.ReadFile(configPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(contents) != "keep" {
+ t.Fatalf("existing template was modified: %q", contents)
+ }
+}
+
+func TestGenerateChallengeTemplateUsesPrivateConfig(t *testing.T) {
+ directory := t.TempDir()
+ if err := generateChallengeTemplate(directory); err != nil {
+ t.Fatal(err)
+ }
+ info, err := os.Stat(filepath.Join(directory, core.CHALLENGE_CONFIG_FILE_NAME))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if info.Mode().Perm() != 0600 {
+ t.Fatalf("template mode = %04o, want 0600", info.Mode().Perm())
+ }
+ configuration, err := challengeConfig.LoadChallengeConfig(filepath.Join(directory, core.CHALLENGE_CONFIG_FILE_NAME))
+ if err != nil {
+ t.Fatalf("generated configuration is not valid TOML: %v", err)
+ }
+ if configuration.Challenge.Metadata.Type != core.STATIC_CHALLENGE_TYPE_NAME || configuration.Challenge.Env.StaticContentDir != core.PUBLIC {
+ t.Fatalf("unexpected generated challenge defaults: %#v", configuration.Challenge)
+ }
+}
diff --git a/cmd/beast/healthprobe.go b/cmd/beast/healthprobe.go
index 65bc5691..a20d2acd 100644
--- a/cmd/beast/healthprobe.go
+++ b/cmd/beast/healthprobe.go
@@ -1,6 +1,12 @@
package main
import (
+ "context"
+ "os"
+ "os/signal"
+ "syscall"
+
+ "github.com/sdslabs/beastv4/core"
"github.com/sdslabs/beastv4/core/config"
"github.com/sdslabs/beastv4/core/manager"
"github.com/spf13/cobra"
@@ -11,9 +17,22 @@ var healthProbeCmd = &cobra.Command{
Short: "Run Health Probe",
Long: "Run Health Probe only without API server",
- Run: func(cmd *cobra.Command, args []string) {
- config.InitConfig()
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ cleanup, err := initializeCLIRuntime(true, true)
+ if err != nil {
+ return err
+ }
+ defer cleanup()
+ controllerLock, err := acquireControllerLock(core.BEAST_GLOBAL_DIR)
+ if err != nil {
+ return err
+ }
+ defer releaseControllerLock(controllerLock)
- go manager.BeastHeathCheckProber(config.Cfg.TickerFrequency)
+ ctx, stopSignals := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ defer stopSignals()
+ manager.BeastHealthCheckProber(ctx, config.Cfg.TickerFrequency)
+ return nil
},
}
diff --git a/cmd/beast/init.go b/cmd/beast/init.go
index 9a62b0a3..ef790d1d 100644
--- a/cmd/beast/init.go
+++ b/cmd/beast/init.go
@@ -5,20 +5,19 @@ import (
"database/sql"
"errors"
"fmt"
- "io"
- "net/http"
+ "net"
+ "net/url"
"os"
- "os/exec"
"os/user"
"path/filepath"
- "strings"
+ "time"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/lib/pq"
"github.com/redis/go-redis/v9"
"github.com/sdslabs/beastv4/core"
+ coreCache "github.com/sdslabs/beastv4/core/cache"
"github.com/sdslabs/beastv4/core/config"
- "github.com/sdslabs/beastv4/core/database"
coreUtils "github.com/sdslabs/beastv4/core/utils"
"github.com/sdslabs/beastv4/utils"
log "github.com/sirupsen/logrus"
@@ -36,10 +35,10 @@ func initDirectories() error {
log.Infoln("Creating beast directories...")
directories := []string{
+ core.BEAST_GLOBAL_DIR,
filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_CACHE_DIR),
filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_REMOTES_DIR),
filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_UPLOADS_DIR),
- filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_SCRIPTS_DIR),
filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_SECRETS_DIR),
filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_STAGING_DIR),
filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_ASSETS_DIR, core.BEAST_LOGO_DIR),
@@ -47,10 +46,13 @@ func initDirectories() error {
}
for _, dir := range directories {
- err := os.MkdirAll(dir, 0755)
+ err := os.MkdirAll(dir, 0700)
if err != nil {
return err
}
+ if err := os.Chmod(dir, 0700); err != nil {
+ return err
+ }
}
return nil
@@ -58,43 +60,26 @@ func initDirectories() error {
func checkDockerDaemon() error {
log.Infoln("Checking docker daemon...")
- _, err := os.Stat(core.DOCKER_PID)
- return err
-}
-
-func installAir() error {
- log.Infoln("Installing air for live reloading...")
-
- resp, err := http.Get("https://raw.githubusercontent.com/cosmtrek/air/master/install.sh")
+ output, err := utils.RunCommand(30*time.Second, nil, "docker", "info", "--format", "{{.ServerVersion}}")
if err != nil {
- return err
- }
- defer resp.Body.Close()
-
- install := "install.sh"
- out, err := os.Create(install)
- if err != nil {
- return err
- }
- defer out.Close()
-
- defer os.Remove(install)
-
- if _, err = io.Copy(out, resp.Body); err != nil {
- return err
+ return fmt.Errorf("Docker daemon is unavailable: %w; output: %s", err, output)
}
+ return nil
+}
- gopath, err := exec.Command("go", "env", "GOPATH").Output()
- if err != nil {
- return err
+func beastRedisACLRules(password string) []string {
+ return []string{
+ "reset", "on", ">" + password,
+ "~beast:*", "&__keyevent@*__:expired",
+ "+ping", "+select", "+client|setinfo",
+ "+get", "+set", "+del", "+expire", "+ttl", "+scan",
+ "+sadd", "+srem", "+smembers", "+sismember",
+ "+lpush", "+rpop", "+llen",
+ "+eval", "+multi", "+exec", "+discard", "+watch", "+unwatch",
+ "+psubscribe", "+punsubscribe",
+ "+config|get", "+config|set",
+ "+flushdb", "+psync", "+replconf",
}
- binDir := filepath.Join(strings.TrimSpace(string(gopath)), "bin")
-
- cmd := exec.Command("sh", install, "-b", binDir)
- cmd.Stdout = os.Stdout
- cmd.Stderr = os.Stderr
-
- return cmd.Run()
}
func createBeastRedisUser(cache *redis.Client, configuration *config.RedisConfig) error {
@@ -113,7 +98,8 @@ func createBeastRedisUser(cache *redis.Client, configuration *config.RedisConfig
}
}
- _, err = cache.ACLSetUser(ctx, configuration.User, "on", ">"+configuration.Password, "~beast:*", "+@all").Result()
+ rules := beastRedisACLRules(configuration.Password)
+ _, err = cache.ACLSetUser(ctx, configuration.User, rules...).Result()
if err != nil {
return err
}
@@ -131,21 +117,35 @@ func initCache() error {
log.Infoln("Initializing cache...")
redisConfig := config.Cfg.RedisConf
+ tlsConfig, err := coreCache.NewTLSConfig(redisConfig.TLS, redisConfig.CAFile, redisConfig.ServerName, redisConfig.Host)
+ if err != nil {
+ return err
+ }
var cache *redis.Client
if utils.PromptBinary("Do you use password authentication for the redis default user?") {
cache = redis.NewClient(&redis.Options{
- Addr: fmt.Sprintf("%s:%s", redisConfig.Host, redisConfig.Port),
- Username: core.REDIS_DEFAULT_USER,
- Password: utils.PromptSecret("Enter default redis user password"),
+ Addr: net.JoinHostPort(redisConfig.Host, redisConfig.Port),
+ Username: core.REDIS_DEFAULT_USER,
+ Password: utils.PromptSecret("Enter default redis user password"),
+ TLSConfig: tlsConfig,
+ DialTimeout: 5 * time.Second,
+ ReadTimeout: 5 * time.Second,
+ WriteTimeout: 5 * time.Second,
})
} else {
cache = redis.NewClient(&redis.Options{
- Addr: fmt.Sprintf("%s:%s", redisConfig.Host, redisConfig.Port),
- Username: core.REDIS_DEFAULT_USER,
+ Addr: net.JoinHostPort(redisConfig.Host, redisConfig.Port),
+ Username: core.REDIS_DEFAULT_USER,
+ TLSConfig: tlsConfig,
+ DialTimeout: 5 * time.Second,
+ ReadTimeout: 5 * time.Second,
+ WriteTimeout: 5 * time.Second,
})
}
- _, err := cache.Ping(context.Background()).Result()
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ _, err = cache.Ping(ctx).Result()
if err != nil {
return fmt.Errorf("failed to connected to redis: %s", err.Error())
}
@@ -181,8 +181,33 @@ func dbUserCheck() (bool, error) {
return current.Username == core.POSTGRES_SUPER_USER, nil
}
+func postgresAdminDSN(configuration config.PsqlConfig, password string) string {
+ dsn := &url.URL{
+ Scheme: "postgresql",
+ User: url.UserPassword(core.POSTGRES_SUPER_USER, password),
+ Host: net.JoinHostPort(configuration.Host, configuration.Port),
+ Path: "postgres",
+ }
+ query := dsn.Query()
+ query.Set("sslmode", configuration.SslMode)
+ if configuration.SSLRootCert != "" {
+ query.Set("sslrootcert", configuration.SSLRootCert)
+ }
+ dsn.RawQuery = query.Encode()
+ return dsn.String()
+}
+
+func isLoopbackHost(host string) bool {
+ if host == "localhost" {
+ return true
+ }
+ ip := net.ParseIP(host)
+ return ip != nil && ip.IsLoopback()
+}
+
func initDb() error {
log.Infoln("Initializing database...")
+ configuration := config.Cfg.PsqlConf
isPostgres, err := dbUserCheck()
if err != nil {
@@ -190,37 +215,29 @@ func initDb() error {
}
var db *sql.DB
- if isPostgres {
+ if isPostgres && isLoopbackHost(configuration.Host) {
log.Infoln("Attempting to connect to postgres as postgres super user...")
- dsn := fmt.Sprintf("user=%s dbname=%s sslmode=%s", "postgres", "postgres", "disable")
- db, err = sql.Open("pgx", dsn)
+ db, err = sql.Open("pgx", "user=postgres dbname=postgres sslmode=disable")
if err != nil {
return err
}
} else {
- log.Warnln("Current user is not postgres super user...")
-
- if utils.PromptBinary("Do you use password authentication for the postgres super user?") {
- password := utils.PromptSecret("Enter postgres super user password (leave blank if none):")
-
- dsn := fmt.Sprintf("user=%s password=%s dbname=%s sslmode=%s", "postgres", password, "postgres", "disable")
- db, err = sql.Open("pgx", dsn)
-
- if err != nil {
- return err
- }
- } else {
+ log.Warnln("A PostgreSQL superuser password is required for this connection...")
+ if !utils.PromptBinary("Connect using password authentication for the postgres superuser?") {
log.Errorln("Cannot continue with postgres setup... Please run this command as the postgres super user (preferred) or use password authentication.")
return errors.New("failed to initialize database")
}
+ password := utils.PromptSecret("Enter postgres superuser password:")
+ db, err = sql.Open("pgx", postgresAdminDSN(configuration, password))
+ if err != nil {
+ return err
+ }
}
defer db.Close()
- configuration := config.Cfg.PsqlConf
-
var exists int
err = db.QueryRow("SELECT 1 FROM pg_roles WHERE rolname = $1", configuration.User).Scan(&exists)
if errors.Is(err, sql.ErrNoRows) {
@@ -262,7 +279,9 @@ func initDb() error {
func initAdmin() error {
if result := utils.PromptBinary("Create an administrative user for beast?"); result {
- config.InitConfig()
+ if err := config.InitConfig(); err != nil {
+ return err
+ }
name := utils.PromptString("Enter admin name")
if name == "" {
@@ -280,22 +299,17 @@ func initAdmin() error {
}
password := utils.PromptSecret("Enter admin password")
- if password == "" {
- return errors.New("admin password is required")
+ confirmation := utils.PromptSecret("Confirm admin password")
+ if password != confirmation || len(password) < 12 || len(password) > 128 {
+ return errors.New("admin password confirmation must match and contain 12 to 128 bytes")
}
- publicKeyPath, err := utils.PromptPublicKeyFile()
- if err != nil {
+ if err := createAuthorAdminPrereq(); err != nil {
return err
}
- if publicKeyPath == "" {
- log.Warnln("No public key provided... proceeding without it")
+ if err := coreUtils.CreateAdminOrAuthor(name, username, email, password, "admin"); err != nil {
+ return err
}
-
- database.Init()
-
- createAuthorAdminPrereq()
- coreUtils.CreateAdminOrAuthor(name, username, email, publicKeyPath, password, "admin")
}
return nil
@@ -315,6 +329,9 @@ func runBeastBootsteps() error {
}
log.Infoln(fmt.Sprintf("Beast global config file initiliazed at %s", BEAST_GLOBAL_CONFIG))
+ if err := ensureLocalTLSCertificate(); err != nil {
+ return err
+ }
if err := checkDockerDaemon(); err != nil {
return err
@@ -322,14 +339,10 @@ func runBeastBootsteps() error {
log.Infoln("Verified Docker Daemon running")
- if err := installAir(); err != nil {
+ if err := config.InitConfig(); err != nil {
return err
}
- log.Infoln("Successfully installed air for live reloading...")
-
- config.InitConfig()
-
if err := initCache(); err != nil {
return err
}
@@ -354,20 +367,18 @@ func runBeastBootsteps() error {
var initCmd = &cobra.Command{
Use: "init",
Short: "Run Beast initial setup bootsetps.",
- Long: "Initializes beast by setting up beast directory, checking for permission. It also configures the logger and local SQLite database to be used by beast",
-
- Run: func(cmd *cobra.Command, args []string) {
- err := runBeastBootsteps()
+ Long: "Initializes Beast directories, configuration, TLS, Redis ACLs, PostgreSQL schema, and an optional administrator account.",
- if err != nil {
- log.Errorln(err.Error())
- log.Errorln("Failed to complete beast bootsteps... fix above errors and try again")
- return
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if err := runBeastBootsteps(); err != nil {
+ return fmt.Errorf("complete Beast bootsteps: %w", err)
}
log.Infoln(COLOR_GREEN + "Please run beast server by following command:-" + RESET)
log.Infoln(COLOR_GREEN + "******************" + RESET)
log.Infoln(COLOR_GREEN + "* " + BLINK_ON + "beast run -v" + BLINK_OFF + " *" + RESET)
log.Infoln(COLOR_GREEN + "******************" + RESET)
+ return nil
},
}
diff --git a/cmd/beast/init_test.go b/cmd/beast/init_test.go
new file mode 100644
index 00000000..fbed0df2
--- /dev/null
+++ b/cmd/beast/init_test.go
@@ -0,0 +1,19 @@
+package main
+
+import (
+ "slices"
+ "testing"
+)
+
+func TestBeastRedisACLRulesUseExplicitCommands(t *testing.T) {
+ rules := beastRedisACLRules("secret")
+ if !slices.Contains(rules, "reset") || !slices.Contains(rules, "~beast:*") {
+ t.Fatalf("ACL rules do not reset and constrain key access: %v", rules)
+ }
+ if slices.Contains(rules, "+@all") {
+ t.Fatalf("ACL rules grant every Redis command: %v", rules)
+ }
+ if !slices.Contains(rules, "+eval") || !slices.Contains(rules, "+config|get") {
+ t.Fatalf("ACL rules omit required Beast commands: %v", rules)
+ }
+}
diff --git a/cmd/beast/maintenance.go b/cmd/beast/maintenance.go
new file mode 100644
index 00000000..c62c5b98
--- /dev/null
+++ b/cmd/beast/maintenance.go
@@ -0,0 +1,31 @@
+package main
+
+import (
+ "fmt"
+
+ "github.com/sdslabs/beastv4/core"
+ "github.com/sdslabs/beastv4/core/cache"
+ "github.com/sdslabs/beastv4/core/config"
+)
+
+func initializeMaintenance(useCache bool) (func(), error) {
+ if err := config.InitConfig(); err != nil {
+ return nil, err
+ }
+ lock, err := acquireControllerLock(core.BEAST_GLOBAL_DIR)
+ if err != nil {
+ return nil, fmt.Errorf("maintenance requires an exclusive controller lock: %w", err)
+ }
+ if useCache {
+ redis := config.Cfg.RedisConf
+ cache.Configure(redis.User, redis.Password, redis.Host, redis.Port, redis.Db, redis.TLS, redis.CAFile, redis.ServerName)
+ }
+ return func() { releaseControllerLock(lock) }, nil
+}
+
+func requireDestructiveConfirmation() error {
+ if !ConfirmDestructive {
+ return fmt.Errorf("destructive operation requires --yes")
+ }
+ return nil
+}
diff --git a/cmd/beast/maintenance_test.go b/cmd/beast/maintenance_test.go
new file mode 100644
index 00000000..83e741e9
--- /dev/null
+++ b/cmd/beast/maintenance_test.go
@@ -0,0 +1,17 @@
+package main
+
+import "testing"
+
+func TestDestructiveMaintenanceRequiresConfirmation(t *testing.T) {
+ previous := ConfirmDestructive
+ ConfirmDestructive = false
+ defer func() { ConfirmDestructive = previous }()
+
+ if err := requireDestructiveConfirmation(); err == nil {
+ t.Fatal("expected destructive operation without --yes to fail")
+ }
+ ConfirmDestructive = true
+ if err := requireDestructiveConfirmation(); err != nil {
+ t.Fatalf("expected confirmed destructive operation to pass: %v", err)
+ }
+}
diff --git a/cmd/beast/run.go b/cmd/beast/run.go
index a582c0d5..f3c4d448 100644
--- a/cmd/beast/run.go
+++ b/cmd/beast/run.go
@@ -1,13 +1,17 @@
package main
import (
+ "context"
"encoding/json"
"fmt"
"github.com/sdslabs/beastv4/core/cache"
+ "io"
"math"
"os"
"os/signal"
"path/filepath"
+ "strconv"
+ "strings"
"syscall"
"github.com/sdslabs/beastv4/core"
@@ -15,12 +19,73 @@ import (
"github.com/sdslabs/beastv4/core/manager"
"github.com/sdslabs/beastv4/pkg/remoteManager"
"github.com/sdslabs/beastv4/pkg/sse"
+ "github.com/sdslabs/beastv4/utils"
"github.com/sdslabs/beastv4/api"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
)
+const controllerLockFile = "controller.lock"
+
+func loadDefaultAuthorPassword(path string) (string, error) {
+ if path == "" {
+ return "", nil
+ }
+ expanded, err := utils.ExpandHomePath(path)
+ if err != nil {
+ return "", err
+ }
+ if err := utils.ValidateSecretFile(expanded); err != nil {
+ return "", fmt.Errorf("validate default author password file: %w", err)
+ }
+ file, err := os.Open(expanded)
+ if err != nil {
+ return "", fmt.Errorf("open default author password file: %w", err)
+ }
+ defer file.Close()
+ contents, err := io.ReadAll(io.LimitReader(file, 130))
+ if err != nil {
+ return "", fmt.Errorf("read default author password file: %w", err)
+ }
+ password := strings.TrimSuffix(strings.TrimSuffix(string(contents), "\n"), "\r")
+ if len(password) < 12 || len(password) > 128 || strings.TrimSpace(password) == "" {
+ return "", fmt.Errorf("default author password must contain between 12 and 128 non-whitespace bytes")
+ }
+ return password, nil
+}
+
+func acquireControllerLock(directory string) (*os.File, error) {
+ path := filepath.Join(directory, controllerLockFile)
+ file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0600)
+ if err != nil {
+ return nil, fmt.Errorf("open controller lock: %w", err)
+ }
+ if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
+ file.Close()
+ return nil, fmt.Errorf("another Beast controller is already active: %w", err)
+ }
+ if err := file.Truncate(0); err != nil {
+ syscall.Flock(int(file.Fd()), syscall.LOCK_UN)
+ file.Close()
+ return nil, fmt.Errorf("truncate controller lock: %w", err)
+ }
+ if _, err := file.WriteString(strconv.Itoa(os.Getpid()) + "\n"); err != nil {
+ syscall.Flock(int(file.Fd()), syscall.LOCK_UN)
+ file.Close()
+ return nil, fmt.Errorf("write controller lock: %w", err)
+ }
+ return file, nil
+}
+
+func releaseControllerLock(file *os.File) {
+ if file == nil {
+ return
+ }
+ _ = syscall.Flock(int(file.Fd()), syscall.LOCK_UN)
+ _ = file.Close()
+}
+
var (
BEAST_GRAPH_CACHE = filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_CACHE_DIR, core.BEAST_GRAPH_CACHE)
BEAST_LEADERBOARD_CACHE = filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_CACHE_DIR, core.BEAST_LEADERBOARD_CACHE)
@@ -35,6 +100,10 @@ func stopApiScheduler() {
func stopWorkerQueue() {
log.Infoln("Stopping the worker queue...")
+ if manager.Q == nil {
+ log.Infoln("Worker queue was not started")
+ return
+ }
manager.Q.Stop()
log.Infoln("Worker queue stopped")
}
@@ -45,45 +114,11 @@ func stopRemoteManagers() {
log.Infoln("Remote Manager queue stopped")
}
-func cleanupRunningContainers() {
- log.Infoln("Cleaning up running challenges...")
-
- challenges, err := database.QueryAllChallenges()
- if err != nil {
- log.Errorln(fmt.Sprintf("Error while querying challenges for cleanup: %s", err.Error()))
- return
- }
-
- for _, challenge := range challenges {
- if challenge.Status == core.DEPLOY_STATUS["deployed"] {
- if challenge.Instanced {
- _ = manager.KillChallengeInstances(challenge.Name)
- }
-
- err = manager.UndeployChallenge(challenge.Name)
- if err != nil {
- log.Errorln(fmt.Sprintf("Failed to undeploy challenge [Id: %v] %s", challenge.ID, challenge.Name))
- log.Errorln(err.Error())
- } else {
- log.Infoln(fmt.Sprintf("Successfully undeployed challenge [Id: %v] %s", challenge.ID, challenge.Name))
- }
- }
- }
-}
-
func cleanupCacheConnections() {
log.Infoln("Cleaning up cache connections...")
-
- err := cache.BackupCache()
- if err != nil {
- log.Errorln("Error while backing up cache:", err)
- } else {
- log.Infoln("Cache backup completed successfully")
- }
-
log.Infoln("Terminating cache connection...")
- err = cache.Close()
+ err := cache.Close()
if err != nil {
log.Errorln("Unable to terminate cache connections:", err)
} else {
@@ -92,20 +127,17 @@ func cleanupCacheConnections() {
}
func cleanupDatabaseConnections() {
- log.Infoln("Backing up database...")
-
- err := database.BackupDatabase()
- if err != nil {
- log.Errorln("Error while backing up database:", err)
- } else {
- log.Infoln("Database backup completed successfully")
- }
-
log.Infoln("Terminating database connection...")
- err = database.TerminateDatabaseConnections()
+ if database.Db == nil {
+ log.Infoln("Database was not initialized")
+ return
+ }
+ sqlDB, err := database.Db.DB()
if err != nil {
- log.Errorln("Unable to terminate database connections:", err)
+ log.Errorln("Unable to access database connection:", err)
+ } else if err := sqlDB.Close(); err != nil {
+ log.Errorln("Unable to close database connection:", err)
} else {
log.Infoln("Database connections terminated successfully")
}
@@ -117,7 +149,31 @@ func writeJson(data any, location string) error {
return err
}
- return os.WriteFile(location, bytes, 0644)
+ temporary, err := os.CreateTemp(filepath.Dir(location), "."+filepath.Base(location)+".tmp-*")
+ if err != nil {
+ return fmt.Errorf("create temporary JSON file: %w", err)
+ }
+ temporaryPath := temporary.Name()
+ defer os.Remove(temporaryPath)
+ if err := temporary.Chmod(0600); err != nil {
+ _ = temporary.Close()
+ return fmt.Errorf("secure temporary JSON file: %w", err)
+ }
+ if _, err := temporary.Write(bytes); err != nil {
+ _ = temporary.Close()
+ return fmt.Errorf("write temporary JSON file: %w", err)
+ }
+ if err := temporary.Sync(); err != nil {
+ _ = temporary.Close()
+ return fmt.Errorf("sync temporary JSON file: %w", err)
+ }
+ if err := temporary.Close(); err != nil {
+ return fmt.Errorf("close temporary JSON file: %w", err)
+ }
+ if err := os.Rename(temporaryPath, location); err != nil {
+ return fmt.Errorf("replace JSON file: %w", err)
+ }
+ return nil
}
func saveLeaderboardCache() {
@@ -153,12 +209,10 @@ func stopSseNotificationHub() {
func cleanup() {
log.Info("Starting graceful shutdown cleanup...")
- stopSseNotificationHub()
stopApiScheduler()
-
- cleanupRunningContainers()
-
stopWorkerQueue()
+ stopSseNotificationHub()
+
stopRemoteManagers()
saveLeaderboardCache()
@@ -179,26 +233,49 @@ var runCmd = &cobra.Command{
Short: "Run Beast API server",
Long: "Run beast API server using beast/api/server, optionally an argument can be provided to specify the port to run the server on.",
- Run: func(cmd *cobra.Command, args []string) {
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, args []string) error {
if _, err := os.Stat(core.BEAST_GLOBAL_DIR); os.IsNotExist(err) {
log.Infof("%s directory not found... running Beast bootsteps...\n", core.BEAST_GLOBAL_DIR)
if err := runBeastBootsteps(); err != nil {
- log.Error("Error while running Beast bootsteps.")
- os.Exit(1)
+ return fmt.Errorf("run Beast bootsteps: %w", err)
}
log.Infoln("beast bootsteps complete... starting beast server")
+ } else if err != nil {
+ return fmt.Errorf("inspect Beast directory: %w", err)
}
+ if Port != "" {
+ port, err := strconv.Atoi(Port)
+ if err != nil || port < 1 || port > 65535 {
+ return fmt.Errorf("invalid API port %q", Port)
+ }
+ }
+ controllerLock, err := acquireControllerLock(core.BEAST_GLOBAL_DIR)
+ if err != nil {
+ return err
+ }
+ defer releaseControllerLock(controllerLock)
- sigChan := make(chan os.Signal, 1)
- signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
-
- go api.RunBeastApiServer(Port, DefaultAuthorPassword, AutoDeploy, HealthProbe, PeriodicSync, NoCache)
- <-sigChan
+ ctx, stopSignals := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ defer stopSignals()
- log.Infoln("\nShutdown signal received.")
- cleanup()
+ defaultAuthorPassword, err := loadDefaultAuthorPassword(DefaultAuthorPasswordFile)
+ if err != nil {
+ return err
+ }
+ err = api.RunBeastApiServer(ctx, Port, defaultAuthorPassword, AutoDeploy, HealthProbe, PeriodicSync, NoCache)
+ if ctx.Err() != nil {
+ log.Infoln("Shutdown signal received.")
+ }
+ if manager.Q != nil && database.Db != nil {
+ cleanup()
+ }
+ if err != nil {
+ return fmt.Errorf("Beast API stopped: %w", err)
+ }
log.Infoln("Server stopped gracefully.")
+ return nil
},
}
diff --git a/cmd/beast/run_test.go b/cmd/beast/run_test.go
new file mode 100644
index 00000000..665782cf
--- /dev/null
+++ b/cmd/beast/run_test.go
@@ -0,0 +1,98 @@
+package main
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestAcquireControllerLockRejectsSecondController(t *testing.T) {
+ directory := t.TempDir()
+ first, err := acquireControllerLock(directory)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer releaseControllerLock(first)
+
+ second, err := acquireControllerLock(directory)
+ if second != nil {
+ releaseControllerLock(second)
+ t.Fatal("second controller acquired lock")
+ }
+ if err == nil || !strings.Contains(err.Error(), "already active") {
+ t.Fatalf("expected active controller error, got %v", err)
+ }
+}
+
+func TestLoadDefaultAuthorPasswordRequiresPrivateFile(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "password")
+ if err := os.WriteFile(path, []byte("a-secure-password\n"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := loadDefaultAuthorPassword(path); err == nil {
+ t.Fatal("expected public password file to fail")
+ }
+ if err := os.Chmod(path, 0600); err != nil {
+ t.Fatal(err)
+ }
+ password, err := loadDefaultAuthorPassword(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if password != "a-secure-password" {
+ t.Fatalf("unexpected password %q", password)
+ }
+}
+
+func TestControllerLockCanBeReacquiredAfterRelease(t *testing.T) {
+ directory := t.TempDir()
+ first, err := acquireControllerLock(directory)
+ if err != nil {
+ t.Fatal(err)
+ }
+ releaseControllerLock(first)
+
+ second, err := acquireControllerLock(directory)
+ if err != nil {
+ t.Fatal(err)
+ }
+ releaseControllerLock(second)
+}
+
+func TestWriteJSONReplacesAtomicallyWithPrivatePermissions(t *testing.T) {
+ directory := t.TempDir()
+ outside := filepath.Join(t.TempDir(), "outside")
+ if err := os.WriteFile(outside, []byte("keep"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ location := filepath.Join(directory, "cache.json")
+ if err := os.Symlink(outside, location); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := writeJson(map[string]string{"state": "fresh"}, location); err != nil {
+ t.Fatal(err)
+ }
+ contents, err := os.ReadFile(location)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(contents) != `{"state":"fresh"}` {
+ t.Fatalf("unexpected JSON cache %q", contents)
+ }
+ outsideContents, err := os.ReadFile(outside)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(outsideContents) != "keep" {
+ t.Fatalf("symlink target was overwritten: %q", outsideContents)
+ }
+ info, err := os.Stat(location)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if info.Mode().Perm() != 0600 {
+ t.Fatalf("cache permissions = %04o, want 0600", info.Mode().Perm())
+ }
+}
diff --git a/cmd/beast/runtime.go b/cmd/beast/runtime.go
new file mode 100644
index 00000000..f82ac7cf
--- /dev/null
+++ b/cmd/beast/runtime.go
@@ -0,0 +1,69 @@
+package main
+
+import (
+ "github.com/sdslabs/beastv4/core"
+ "github.com/sdslabs/beastv4/core/cache"
+ "github.com/sdslabs/beastv4/core/config"
+ "github.com/sdslabs/beastv4/core/database"
+ "github.com/sdslabs/beastv4/pkg/auth"
+ "github.com/sdslabs/beastv4/pkg/remoteManager"
+ log "github.com/sirupsen/logrus"
+)
+
+func initializeCLIRuntime(requireCache, requireRemotes bool) (func(), error) {
+ if err := config.InitConfig(); err != nil {
+ return nil, err
+ }
+ auth.Init(core.ITERATIONS, core.HASH_LENGTH, core.TIMEPERIOD, core.ISSUER, config.Cfg.JWTSecret, []string{core.USER_ROLES["author"], core.USER_ROLES["maintainer"]}, []string{core.USER_ROLES["admin"]}, []string{core.USER_ROLES["contestant"]})
+ if err := database.Init(); err != nil {
+ closeCLIDatabase()
+ return nil, err
+ }
+
+ cacheInitialized := false
+ if requireCache {
+ redis := config.Cfg.RedisConf
+ cache.Configure(redis.User, redis.Password, redis.Host, redis.Port, redis.Db, redis.TLS, redis.CAFile, redis.ServerName)
+ if err := cache.Init(); err != nil {
+ closeCLIDatabase()
+ return nil, err
+ }
+ cacheInitialized = true
+ }
+ if requireRemotes {
+ if err := remoteManager.Init(); err != nil {
+ if cacheInitialized {
+ _ = cache.Close()
+ }
+ closeCLIDatabase()
+ return nil, err
+ }
+ }
+
+ return func() {
+ if requireRemotes {
+ remoteManager.Stop()
+ }
+ if cacheInitialized {
+ if err := cache.Close(); err != nil {
+ log.Warnf("close CLI cache: %v", err)
+ }
+ }
+ closeCLIDatabase()
+ }, nil
+}
+
+func closeCLIDatabase() {
+ if database.Db == nil {
+ return
+ }
+ sqlDB, err := database.Db.DB()
+ if err != nil {
+ log.Warnf("access CLI database connection: %v", err)
+ return
+ }
+ if err := sqlDB.Close(); err != nil {
+ log.Warnf("close CLI database connection: %v", err)
+ }
+ database.Db = nil
+}
diff --git a/cmd/beast/tls.go b/cmd/beast/tls.go
new file mode 100644
index 00000000..fa0da1e1
--- /dev/null
+++ b/cmd/beast/tls.go
@@ -0,0 +1,88 @@
+package main
+
+import (
+ "crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/rand"
+ "crypto/x509"
+ "crypto/x509/pkix"
+ "encoding/pem"
+ "fmt"
+ "math/big"
+ "net"
+ "os"
+ "path/filepath"
+ "time"
+
+ "github.com/sdslabs/beastv4/core"
+)
+
+func ensureLocalTLSCertificate() error {
+ certPath := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_SECRETS_DIR, "tls.crt")
+ keyPath := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_SECRETS_DIR, "tls.key")
+ certInfo, certErr := os.Lstat(certPath)
+ keyInfo, keyErr := os.Lstat(keyPath)
+ if certErr == nil && keyErr == nil {
+ if !certInfo.Mode().IsRegular() || !keyInfo.Mode().IsRegular() || keyInfo.Mode().Perm() != 0600 {
+ return fmt.Errorf("existing TLS certificate and key must be regular files and the key must be 0600")
+ }
+ return nil
+ }
+ if !os.IsNotExist(certErr) || !os.IsNotExist(keyErr) || os.IsNotExist(certErr) != os.IsNotExist(keyErr) {
+ return fmt.Errorf("TLS certificate and key must either both exist or both be absent")
+ }
+
+ privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ if err != nil {
+ return fmt.Errorf("generate TLS private key: %w", err)
+ }
+ serialLimit := new(big.Int).Lsh(big.NewInt(1), 128)
+ serial, err := rand.Int(rand.Reader, serialLimit)
+ if err != nil {
+ return fmt.Errorf("generate certificate serial: %w", err)
+ }
+ now := time.Now()
+ template := &x509.Certificate{
+ SerialNumber: serial,
+ Subject: pkix.Name{CommonName: "localhost"},
+ NotBefore: now.Add(-5 * time.Minute),
+ NotAfter: now.AddDate(1, 0, 0),
+ KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
+ ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
+ DNSNames: []string{"localhost"},
+ IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")},
+ }
+ certificate, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey)
+ if err != nil {
+ return fmt.Errorf("create TLS certificate: %w", err)
+ }
+ keyBytes, err := x509.MarshalPKCS8PrivateKey(privateKey)
+ if err != nil {
+ return fmt.Errorf("marshal TLS private key: %w", err)
+ }
+ if err := writeExclusivePEM(keyPath, 0600, "PRIVATE KEY", keyBytes); err != nil {
+ return err
+ }
+ if err := writeExclusivePEM(certPath, 0644, "CERTIFICATE", certificate); err != nil {
+ _ = os.Remove(keyPath)
+ return err
+ }
+ return nil
+}
+
+func writeExclusivePEM(path string, mode os.FileMode, blockType string, contents []byte) error {
+ file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
+ if err != nil {
+ return fmt.Errorf("create %s: %w", path, err)
+ }
+ if err := pem.Encode(file, &pem.Block{Type: blockType, Bytes: contents}); err != nil {
+ file.Close()
+ _ = os.Remove(path)
+ return fmt.Errorf("write %s: %w", path, err)
+ }
+ if err := file.Close(); err != nil {
+ _ = os.Remove(path)
+ return fmt.Errorf("close %s: %w", path, err)
+ }
+ return nil
+}
diff --git a/cmd/beast/tls_test.go b/cmd/beast/tls_test.go
new file mode 100644
index 00000000..8ad12eac
--- /dev/null
+++ b/cmd/beast/tls_test.go
@@ -0,0 +1,31 @@
+package main
+
+import (
+ "crypto/tls"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/sdslabs/beastv4/core"
+)
+
+func TestEnsureLocalTLSCertificate(t *testing.T) {
+ previous := core.BEAST_GLOBAL_DIR
+ core.BEAST_GLOBAL_DIR = t.TempDir()
+ defer func() { core.BEAST_GLOBAL_DIR = previous }()
+ if err := os.MkdirAll(filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_SECRETS_DIR), 0700); err != nil {
+ t.Fatal(err)
+ }
+ if err := ensureLocalTLSCertificate(); err != nil {
+ t.Fatal(err)
+ }
+ certPath := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_SECRETS_DIR, "tls.crt")
+ keyPath := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_SECRETS_DIR, "tls.key")
+ if _, err := tls.LoadX509KeyPair(certPath, keyPath); err != nil {
+ t.Fatalf("load generated key pair: %v", err)
+ }
+ info, err := os.Stat(keyPath)
+ if err != nil || info.Mode().Perm() != 0600 {
+ t.Fatalf("unexpected key permissions: %v, %v", info, err)
+ }
+}
diff --git a/cmd/beast/verify.go b/cmd/beast/verify.go
index 8ef4bddd..1fff895f 100644
--- a/cmd/beast/verify.go
+++ b/cmd/beast/verify.go
@@ -1,34 +1,46 @@
package main
import (
+ "fmt"
+
"github.com/sdslabs/beastv4/core/config"
"github.com/sdslabs/beastv4/core/manager"
coreUtils "github.com/sdslabs/beastv4/core/utils"
- log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
)
// Verifies the challenge config
var verifyCmd = &cobra.Command{
- Use: "verify challenge-name",
+ Use: "verify [challenge-name]",
Short: "Verifies challenge config",
- Args: cobra.MinimumNArgs(1),
+ Args: cobra.MaximumNArgs(1),
- Run: func(cmd *cobra.Command, args []string) {
- config.InitConfig()
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if err := config.InitConfig(); err != nil {
+ return err
+ }
+ if LocalDirectory != "" {
+ if len(args) != 0 {
+ return fmt.Errorf("challenge name and local-directory are mutually exclusive")
+ }
+ if err := manager.ValidateChallengeConfig(LocalDirectory); err != nil {
+ return fmt.Errorf("validate local challenge: %w", err)
+ }
+ return nil
+ }
+ if len(args) == 0 {
+ return fmt.Errorf("challenge name or local-directory is required")
+ }
challengeName := args[0]
challengeDir := coreUtils.GetChallengeDir(challengeName)
if challengeDir == "" {
- log.Errorf("Challenge does not exist")
- return
+ return fmt.Errorf("challenge %q does not exist", challengeName)
}
- err := manager.ValidateChallengeConfig(challengeDir)
- if err != nil {
- log.Warnf("Error while validating challenge %s : %s", challengeName, err.Error())
- } else {
- log.Infof("The challenge config is verified.")
+ if err := manager.ValidateChallengeConfig(challengeDir); err != nil {
+ return fmt.Errorf("validate challenge %s: %w", challengeName, err)
}
+ return nil
},
}
diff --git a/cmd/beast/version.go b/cmd/beast/version.go
index b4c225a4..bebd31e2 100644
--- a/cmd/beast/version.go
+++ b/cmd/beast/version.go
@@ -2,7 +2,6 @@ package main
import (
"fmt"
- "os"
"github.com/sdslabs/beastv4/version"
"github.com/spf13/cobra"
@@ -16,6 +15,7 @@ var versionCmd = &cobra.Command{
Short: "Displays the version of the current build of beast",
Long: `Displays the version of the current build of beast, this information
include Version, Revision, Git-Branch, BuildUser, BuildDate, go-version`,
+ Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf(version.VersionStr,
@@ -25,6 +25,5 @@ include Version, Revision, Git-Branch, BuildUser, BuildDate, go-version`,
version.Info["buildUser"],
version.Info["buildDate"],
version.Info["goVersion"])
- os.Exit(0)
},
}
diff --git a/core/auth/ssh.go b/core/auth/ssh.go
deleted file mode 100644
index b79d09c9..00000000
--- a/core/auth/ssh.go
+++ /dev/null
@@ -1,110 +0,0 @@
-package auth
-
-import (
- "bytes"
- "crypto/rsa"
- "crypto/sha256"
- "crypto/x509"
- "encoding/pem"
- "errors"
- "fmt"
- "html/template"
- "io/ioutil"
- "path/filepath"
-
- "github.com/sdslabs/beastv4/core"
- "github.com/sdslabs/beastv4/core/database"
- "github.com/sdslabs/beastv4/templates"
- "github.com/sdslabs/beastv4/utils"
- log "github.com/sirupsen/logrus"
- "golang.org/x/crypto/ssh"
-)
-
-// This function takes in the path to the authorized keys file and parses it
-// producing a map with ssh-public key as key and options corresponding to that
-// as the value to corresponding key in the map.
-func ParseAuthorizedKeysFile(filePath string) (map[string][]string, error) {
- authorizedKeysMap := map[string][]string{}
- err := utils.ValidateFileExists(filePath)
- if err != nil {
- log.Error("Error while validating authorized_keys file path")
- return authorizedKeysMap, err
- }
-
- authorizedKeysBytes, err := ioutil.ReadFile(filePath)
- if err != nil {
- eMsg := fmt.Errorf("Failed to load authorized_keys, err: %v", err)
- log.Errorf("Error : %s", eMsg)
- return authorizedKeysMap, eMsg
- }
-
- var eMsg error
-
- for len(authorizedKeysBytes) > 0 {
- pubKey, _, options, rest, err := ssh.ParseAuthorizedKey(authorizedKeysBytes)
- if err != nil {
- eMsg = fmt.Errorf("Error while parsing authorized_keys file : %s", err)
- log.Error(eMsg.Error())
- break
- }
-
- authorizedKeysMap[string(pubKey.Marshal())] = options
- authorizedKeysBytes = rest
- }
-
- return authorizedKeysMap, eMsg
-}
-
-// This function parses ssh Private Key
-func ParsePrivateKey(keyFile string) (*rsa.PrivateKey, error) {
- keyString, err := ioutil.ReadFile(keyFile)
- if err != nil {
- return nil, err
- }
-
- block, _ := pem.Decode(keyString)
- if block == nil {
- return nil, errors.New("Unable to decode")
- }
- key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
- if err != nil {
- return nil, err
- }
-
- return key, nil
-}
-
-// This function disables the SSH based container access to all the current users
-func DisableUserSSH() {
- users, err := database.QueryAllUsers()
- if err != nil {
- log.Errorf("DB ERROR : %v", err)
- return
- }
- for _, user := range users {
- SHA256 := sha256.New()
- SHA256.Write([]byte(user.Email))
- scriptPath := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_SCRIPTS_DIR, fmt.Sprintf("%x", SHA256.Sum(nil)))
-
- data := database.ScriptFile{
- User: user.Name,
- }
-
- var script bytes.Buffer
- scriptTemplate, err := template.New("script").Parse(templates.SSH_RESTRICT_LOGIN_SCRIPT_TEMPLATE)
- if err != nil {
- log.Errorf("Error while parsing script template :: %v", err)
- continue
- }
-
- if err = scriptTemplate.Execute(&script, data); err != nil {
- log.Errorf("Error while executing script template :: %v", err)
- continue
- }
-
- if err = ioutil.WriteFile(scriptPath, script.Bytes(), 0755); err != nil {
- log.Errorf("Error while writing to the script file :: %v", err)
- continue
- }
- }
-}
diff --git a/core/cache/cache.go b/core/cache/cache.go
index 68bfe7ba..99a0d42c 100644
--- a/core/cache/cache.go
+++ b/core/cache/cache.go
@@ -2,16 +2,17 @@ package cache
import (
"context"
+ "crypto/tls"
+ "crypto/x509"
"fmt"
+ "net"
"os"
- "os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
- "github.com/BurntSushi/toml"
"github.com/redis/go-redis/v9"
"github.com/sdslabs/beastv4/core"
"github.com/sdslabs/beastv4/utils"
@@ -19,51 +20,93 @@ import (
)
var (
- CacheMutex *sync.Mutex
+ CacheMutex *sync.RWMutex
Cache *redis.Client
- cacheError error
)
-var (
- BEAST_GLOBAL_DIR string = filepath.Join(os.Getenv("HOME"), ".beast")
- cacheConfig Config
-)
+var cacheConfig RedisConfig
-type Config struct {
- RedisConfig RedisConfig `toml:"redis_config"`
-}
type RedisConfig struct {
- User string `toml:"user"`
- Password string `toml:"password"`
- Host string `toml:"host"`
- Port string `toml:"port"`
- DB int `toml:"db"`
+ User string
+ Password string
+ Host string
+ Port string
+ DB uint32
+ TLS bool
+ CAFile string
+ ServerName string
+}
+
+func Configure(user, password, host, port string, db uint32, tlsEnabled bool, caFile, serverName string) {
+ cacheConfig = RedisConfig{User: user, Password: password, Host: host, Port: port, DB: db, TLS: tlsEnabled, CAFile: caFile, ServerName: serverName}
+}
+
+func LoadCacheConfig() error {
+ if cacheConfig.Host == "" || cacheConfig.Port == "" || cacheConfig.Password == "" {
+ return fmt.Errorf("cache is not configured")
+ }
+ return nil
}
-// Db config is loaded separately here for temp use because init() function is
-// called during initialization of package.
-// It is also loaded during db backup/reset
-func LoadCacheConfig() {
- if _, err := toml.DecodeFile(filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_CONFIG_FILE_NAME), &cacheConfig); err != nil {
- log.Fatalf("Error loading TOML file: %v", err)
+func redisAddress(config RedisConfig) string {
+ return net.JoinHostPort(config.Host, config.Port)
+}
+
+func NewTLSConfig(enabled bool, caFile, serverName, host string) (*tls.Config, error) {
+ if !enabled {
+ return nil, nil
+ }
+ if serverName == "" {
+ serverName = host
}
+ tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12, ServerName: serverName}
+ if caFile == "" {
+ return tlsConfig, nil
+ }
+ certificate, err := os.ReadFile(caFile)
+ if err != nil {
+ return nil, fmt.Errorf("read Redis CA file: %w", err)
+ }
+ roots, err := x509.SystemCertPool()
+ if err != nil || roots == nil {
+ roots = x509.NewCertPool()
+ }
+ if !roots.AppendCertsFromPEM(certificate) {
+ return nil, fmt.Errorf("Redis CA file contains no certificates")
+ }
+ tlsConfig.RootCAs = roots
+ return tlsConfig, nil
}
// Connect redis
func ConnectCache() error {
- LoadCacheConfig()
- Cache = redis.NewClient(&redis.Options{
- Addr: fmt.Sprintf("%s:%s", cacheConfig.RedisConfig.Host, cacheConfig.RedisConfig.Port),
- Username: cacheConfig.RedisConfig.User,
- Password: cacheConfig.RedisConfig.Password,
- DB: cacheConfig.RedisConfig.DB,
+ if err := LoadCacheConfig(); err != nil {
+ return err
+ }
+ tlsConfig, err := NewTLSConfig(cacheConfig.TLS, cacheConfig.CAFile, cacheConfig.ServerName, cacheConfig.Host)
+ if err != nil {
+ return err
+ }
+ client := redis.NewClient(&redis.Options{
+ Addr: redisAddress(cacheConfig),
+ Username: cacheConfig.User,
+ Password: cacheConfig.Password,
+ DB: int(cacheConfig.DB),
+ TLSConfig: tlsConfig,
+ DialTimeout: 5 * time.Second,
+ ReadTimeout: 5 * time.Second,
+ WriteTimeout: 5 * time.Second,
})
- _, err := Cache.Ping(context.Background()).Result()
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ _, err = client.Ping(ctx).Result()
if err != nil {
- return fmt.Errorf("failed to connected to redis: %s", err.Error())
+ _ = client.Close()
+ return fmt.Errorf("connect to Redis: %w", err)
}
+ Cache = client
log.Debug("Cache initialized")
return nil
}
@@ -72,14 +115,14 @@ func ConnectCache() error {
// Postgresql database for beast. The Db variable is the connection variable for the
// database, which is not closed after creating a connection here and can
// be used further after this.
-func Init() {
- CacheMutex = &sync.Mutex{}
+func Init() error {
+ CacheMutex = &sync.RWMutex{}
if Cache == nil {
- cacheError = ConnectCache()
- if cacheError != nil {
- log.Errorf("Error while initializing cache: %s", cacheError.Error())
+ if err := ConnectCache(); err != nil {
+ return fmt.Errorf("initialize cache: %w", err)
}
}
+ return nil
}
func EnableKeyspaceExpiryNotifications() error {
@@ -118,7 +161,7 @@ func SubscribeExpiredInstanceMarkers(ctx context.Context, handler func(instanceI
return fmt.Errorf("redis cache not initialized")
}
- pattern := fmt.Sprintf("__keyevent@%d__:expired", cacheConfig.RedisConfig.DB)
+ pattern := fmt.Sprintf("__keyevent@%d__:expired", cacheConfig.DB)
pubsub := Cache.PSubscribe(ctx, pattern)
defer pubsub.Close()
@@ -153,62 +196,30 @@ func Close() error {
err := Cache.Close()
if err != nil {
- log.Errorln(fmt.Sprintf("Error while closing cache connection gracefully: %s, attempting to terminate forcefully", err.Error()))
- return TerminateCacheConnections()
+ return fmt.Errorf("close cache connection: %w", err)
}
-
+ Cache = nil
return nil
}
-func BackupAndReset() {
- LoadCacheConfig()
-
- err := BackupCache()
- if err != nil {
- log.Errorf("Error while backing up cache: %s", err)
- return
- }
- err = ResetCache()
- if err != nil {
- log.Errorf("Error while resetting up cache: %s", err)
- return
- }
-
- backupPath := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_BACKUP_DIR, core.BEAST_REMOTES_DIR)
- err = utils.CreateIfNotExistDir(backupPath)
- if err != nil {
- log.Errorf("Error while creating backup directory: %s", err)
- return
- }
-
- backupPath = filepath.Join(backupPath, core.BEAST_REMOTES_DIR+time.Now().Format("20060102150405")+".bak")
- oldPath := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_REMOTES_DIR)
- err = os.Rename(oldPath, backupPath)
- if err != nil {
- log.Errorf("Error while backing up remote dir: %s", err)
- return
+func BackupAndReset() error {
+ if err := LoadCacheConfig(); err != nil {
+ return err
}
-
- backupPath = filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_BACKUP_DIR, core.BEAST_STAGING_DIR)
-
- err = utils.CreateIfNotExistDir(backupPath)
- if err != nil {
- log.Errorf("Error while creating backup directory: %s", err)
- return
+ if err := BackupCache(); err != nil {
+ return fmt.Errorf("back up cache: %w", err)
}
-
- oldPath = filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_STAGING_DIR)
- backupPath = filepath.Join(backupPath, core.BEAST_STAGING_DIR+time.Now().Format("20060102150405")+".bak")
- err = os.Rename(oldPath, backupPath)
- if err != nil {
- log.Errorf("Error while backing up staging dir: %s", err)
- return
+ if err := ResetCache(); err != nil {
+ return fmt.Errorf("reset cache: %w", err)
}
+ return nil
}
func BackupCache() error {
- if cacheConfig == (Config{}) {
- LoadCacheConfig()
+ if cacheConfig == (RedisConfig{}) {
+ if err := LoadCacheConfig(); err != nil {
+ return err
+ }
}
backupPath := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_BACKUP_DIR, core.BEAST_CACHE_DIR)
@@ -218,94 +229,56 @@ func BackupCache() error {
return err
}
- backupFile := fmt.Sprintf("%d_%s.bak", cacheConfig.RedisConfig.DB, time.Now().Format("20060102150405"))
-
- args := []string{
- "-h", cacheConfig.RedisConfig.Host,
- "-p", cacheConfig.RedisConfig.Port,
- "-n", strconv.Itoa(cacheConfig.RedisConfig.DB),
- "--rdb", filepath.Join(backupPath, backupFile),
- }
- if cacheConfig.RedisConfig.User != "" {
- args = append(args, "--user", cacheConfig.RedisConfig.User)
- }
- if cacheConfig.RedisConfig.Password != "" {
- args = append(args, "--pass", cacheConfig.RedisConfig.Password)
- }
+ backupFile := fmt.Sprintf("%d_%s.bak", cacheConfig.DB, time.Now().Format("20060102150405"))
- cmd := exec.Command("redis-cli", args...)
+ args := redisCLIConnectionArgs()
+ args = append(args, "--rdb", filepath.Join(backupPath, backupFile))
- cmd.Env = append(os.Environ(), fmt.Sprintf("REDISCLI_AUTH=%s", cacheConfig.RedisConfig.Password))
- output, err := cmd.CombinedOutput()
+ environment := append(os.Environ(), fmt.Sprintf("REDISCLI_AUTH=%s", cacheConfig.Password))
+ output, err := utils.RunCommand(30*time.Minute, environment, "redis-cli", args...)
if err != nil {
- log.Printf("Backup error: %s\n", string(output))
- return err
+ return fmt.Errorf("back up Redis: %w; output: %s", err, output)
}
log.Debug("Backup successful.")
return nil
}
func ResetCache() error {
- if cacheConfig == (Config{}) {
- LoadCacheConfig()
- }
- err := TerminateCacheConnections()
- if err != nil {
- log.Errorf("Unable to terminate connections %s", err)
- return err
+ if cacheConfig == (RedisConfig{}) {
+ if err := LoadCacheConfig(); err != nil {
+ return err
+ }
}
-
- dropCmd := exec.Command(
- "redis-cli",
- "-h", cacheConfig.RedisConfig.Host,
- "-p", cacheConfig.RedisConfig.Port,
- "--user", cacheConfig.RedisConfig.User,
- "-n", strconv.Itoa(cacheConfig.RedisConfig.DB),
- "FLUSHDB",
- )
-
- dropCmd.Env = append(os.Environ(), fmt.Sprintf("REDISCLI_AUTH=%s", cacheConfig.RedisConfig.Password))
-
- output, err := dropCmd.CombinedOutput()
+ args := append(redisCLIConnectionArgs(), "FLUSHDB")
+ environment := append(os.Environ(), fmt.Sprintf("REDISCLI_AUTH=%s", cacheConfig.Password))
+ output, err := utils.RunCommand(2*time.Minute, environment, "redis-cli", args...)
if err != nil {
- log.Printf("Drop Cache error: %s\n", string(output))
- return err
+ return fmt.Errorf("flush Redis database: %w; output: %s", err, output)
}
log.Debug("Reset successful.")
return nil
}
-// Terminate all active connections before dropping
-func TerminateCacheConnections() error {
- if cacheConfig == (Config{}) {
- LoadCacheConfig()
+func redisCLIConnectionArgs() []string {
+ args := []string{
+ "-h", cacheConfig.Host,
+ "-p", cacheConfig.Port,
+ "-n", strconv.FormatUint(uint64(cacheConfig.DB), 10),
}
-
- cache := redis.NewClient(&redis.Options{
- Addr: fmt.Sprintf("%s:%s", cacheConfig.RedisConfig.Host, cacheConfig.RedisConfig.Port),
- Username: core.REDIS_DEFAULT_USER,
- Password: utils.PromptSecret("Enter default redis user password"),
- })
-
- _, err := cache.Ping(context.Background()).Result()
- if err != nil {
- log.Errorf("Terminate connections error: %s\n", err.Error())
+ if cacheConfig.User != "" {
+ args = append(args, "--user", cacheConfig.User)
}
-
- defer cache.Close()
-
- _, err = cache.Do(context.Background(),
- "CLIENT", "KILL",
- "USER", cacheConfig.RedisConfig.User,
- "SKIPME", "yes",
- ).Result()
-
- if err != nil {
- log.Errorf("Terminate connections error: %s\n", err.Error())
+ if cacheConfig.TLS {
+ args = append(args, "--tls")
+ if cacheConfig.CAFile != "" {
+ args = append(args, "--cacert", cacheConfig.CAFile)
+ }
+ if cacheConfig.ServerName != "" {
+ args = append(args, "--sni", cacheConfig.ServerName)
+ }
}
-
- return nil
+ return args
}
func RestoreCache(backupFile string) error {
@@ -313,5 +286,5 @@ func RestoreCache(backupFile string) error {
The primary issue with restoring cache is that it needs to be written to /var/lib and redis needs to be restarted.
Redis will then pick up the changes and continue from there.
*/
- return nil
+ return fmt.Errorf("Redis restore is not implemented; no data was changed")
}
diff --git a/core/cache/cache_test.go b/core/cache/cache_test.go
new file mode 100644
index 00000000..07eb740c
--- /dev/null
+++ b/core/cache/cache_test.go
@@ -0,0 +1,37 @@
+package cache
+
+import (
+ "crypto/tls"
+ "slices"
+ "testing"
+)
+
+func TestRedisAddressSupportsIPv6(t *testing.T) {
+ got := redisAddress(RedisConfig{Host: "2001:db8::1", Port: "6379"})
+ if got != "[2001:db8::1]:6379" {
+ t.Fatalf("redisAddress() = %q", got)
+ }
+}
+
+func TestRedisCLIConnectionArgsIncludeTLS(t *testing.T) {
+ previous := cacheConfig
+ cacheConfig = RedisConfig{Host: "redis.example.com", Port: "6380", User: "beast", DB: 2, TLS: true, CAFile: "/ca.pem", ServerName: "redis.example.com"}
+ defer func() { cacheConfig = previous }()
+
+ arguments := redisCLIConnectionArgs()
+ for _, expected := range []string{"--tls", "--cacert", "/ca.pem", "--sni", "redis.example.com"} {
+ if !slices.Contains(arguments, expected) {
+ t.Fatalf("missing %q from Redis CLI arguments: %v", expected, arguments)
+ }
+ }
+}
+
+func TestRedisTLSConfigRequiresTLS12(t *testing.T) {
+ config, err := NewTLSConfig(true, "", "redis.example.com", "redis.example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if config == nil || config.MinVersion != tls.VersionTLS12 || config.ServerName != "redis.example.com" {
+ t.Fatalf("unexpected TLS config: %+v", config)
+ }
+}
diff --git a/core/cache/instance.go b/core/cache/instance.go
index 9f39c35c..78720b98 100644
--- a/core/cache/instance.go
+++ b/core/cache/instance.go
@@ -47,26 +47,15 @@ func SaveInstance(instance *Instance, ttl time.Duration) error {
}
key := utils.InstanceToKey(instance.InstanceID)
- err = Cache.Set(ctx, key, data, 0).Err()
- if err != nil {
- return fmt.Errorf("failed to save instance: %w", err)
- }
-
expiryKey := utils.InstanceExpiryToKey(instance.InstanceID)
- err = Cache.Set(ctx, expiryKey, instance.InstanceID, ttl).Err()
- if err != nil {
- return fmt.Errorf("failed to save instance expiry marker: %w", err)
- }
-
userKey := utils.UserChallengeToKey(instance.UserID, instance.ChallengeName)
- err = Cache.Set(ctx, userKey, instance.InstanceID, ttl).Err()
- if err != nil {
- return fmt.Errorf("failed to save user instance mapping: %w", err)
- }
-
- err = Cache.SAdd(ctx, utils.InstancesSetKey, instance.InstanceID).Err()
- if err != nil {
- log.Warnf("failed to add instance to set: %v", err)
+ pipe := Cache.TxPipeline()
+ pipe.Set(ctx, key, data, 0)
+ pipe.Set(ctx, expiryKey, instance.InstanceID, ttl)
+ pipe.Set(ctx, userKey, instance.InstanceID, ttl)
+ pipe.SAdd(ctx, utils.InstancesSetKey, instance.InstanceID)
+ if _, err := pipe.Exec(ctx); err != nil {
+ return fmt.Errorf("save instance metadata: %w", err)
}
log.Debugf("Saved instance %s for user %s, challenge %s, port %d, expires in %v",
@@ -81,8 +70,8 @@ func GetInstance(instanceID string) (*Instance, error) {
}
ctx := context.Background()
- CacheMutex.Lock()
- defer CacheMutex.Unlock()
+ CacheMutex.RLock()
+ defer CacheMutex.RUnlock()
key := utils.InstanceToKey(instanceID)
data, err := Cache.Get(ctx, key).Bytes()
@@ -105,8 +94,8 @@ func GetUserInstance(userID, challengeName string) (*Instance, error) {
}
ctx := context.Background()
- CacheMutex.Lock()
- defer CacheMutex.Unlock()
+ CacheMutex.RLock()
+ defer CacheMutex.RUnlock()
userKey := utils.UserChallengeToKey(userID, challengeName)
instanceID, err := Cache.Get(ctx, userKey).Result()
@@ -135,8 +124,8 @@ func GetUserInstances(userID string) ([]*Instance, error) {
}
ctx := context.Background()
- CacheMutex.Lock()
- defer CacheMutex.Unlock()
+ CacheMutex.RLock()
+ defer CacheMutex.RUnlock()
pattern := utils.UserChallengesAllKey(userID)
var instances []*Instance
@@ -260,16 +249,16 @@ func DeleteInstance(instanceID string) error {
return fmt.Errorf("failed to unmarshal instance: %w", err)
}
- err = Cache.Del(ctx, key).Err()
- if err != nil {
- return fmt.Errorf("failed to delete instance: %w", err)
- }
-
userKey := utils.UserChallengeToKey(instance.UserID, instance.ChallengeName)
expiryKey := utils.InstanceExpiryToKey(instanceID)
- Cache.Del(ctx, userKey)
- Cache.Del(ctx, expiryKey)
- Cache.SRem(ctx, utils.InstancesSetKey, instanceID)
+ pipe := Cache.TxPipeline()
+ pipe.Del(ctx, key)
+ pipe.Del(ctx, userKey)
+ pipe.Del(ctx, expiryKey)
+ pipe.SRem(ctx, utils.InstancesSetKey, instanceID)
+ if _, err := pipe.Exec(ctx); err != nil {
+ return fmt.Errorf("delete instance metadata: %w", err)
+ }
log.Debugf("Deleted instance %s for user %s, challenge %s",
instanceID, instance.UserID, instance.ChallengeName)
@@ -312,15 +301,15 @@ func ExtendInstance(instanceID string, additionalTime time.Duration) error {
return fmt.Errorf("failed to marshal instance: %w", err)
}
- err = Cache.Set(ctx, key, updatedData, 0).Err()
- if err != nil {
- return fmt.Errorf("failed to extend instance: %w", err)
- }
-
userKey := utils.UserChallengeToKey(instance.UserID, instance.ChallengeName)
expiryKey := utils.InstanceExpiryToKey(instanceID)
- Cache.Set(ctx, expiryKey, instanceID, newTTL)
- Cache.Expire(ctx, userKey, newTTL)
+ pipe := Cache.TxPipeline()
+ pipe.Set(ctx, key, updatedData, 0)
+ pipe.Set(ctx, expiryKey, instanceID, newTTL)
+ pipe.Expire(ctx, userKey, newTTL)
+ if _, err := pipe.Exec(ctx); err != nil {
+ return fmt.Errorf("extend instance metadata: %w", err)
+ }
log.Debugf("Extended instance %s by %v, new expiration: %v", instanceID, additionalTime, newExpiresAt)
@@ -333,8 +322,8 @@ func CountUserInstances(userID string) (int, error) {
}
ctx := context.Background()
- CacheMutex.Lock()
- defer CacheMutex.Unlock()
+ CacheMutex.RLock()
+ defer CacheMutex.RUnlock()
pattern := utils.UserChallengesAllKey(userID)
count := 0
@@ -353,8 +342,8 @@ func GetInstanceTTL(instanceID string) (time.Duration, error) {
}
ctx := context.Background()
- CacheMutex.Lock()
- defer CacheMutex.Unlock()
+ CacheMutex.RLock()
+ defer CacheMutex.RUnlock()
expiryKey := utils.InstanceExpiryToKey(instanceID)
ttl, err := Cache.TTL(ctx, expiryKey).Result()
@@ -495,8 +484,8 @@ func GetDeletionQueueLength() (int64, error) {
}
ctx := context.Background()
- CacheMutex.Lock()
- defer CacheMutex.Unlock()
+ CacheMutex.RLock()
+ defer CacheMutex.RUnlock()
return Cache.LLen(ctx, utils.InstanceDeletionQueue).Result()
}
diff --git a/core/cache/ports.go b/core/cache/ports.go
index ab9e4b65..85bfc1dd 100644
--- a/core/cache/ports.go
+++ b/core/cache/ports.go
@@ -36,6 +36,15 @@ end
return selected
`
+const freeContainerPortsScript = `
+local ports = redis.call("SMEMBERS", KEYS[2])
+for _, port in ipairs(ports) do
+ redis.call("SREM", KEYS[1], port)
+end
+redis.call("DEL", KEYS[2])
+return ports
+`
+
// GetFreePortOnHost gets the first available port in the specific range by checking its existance in the cache.
// algorithm can be imprived later on if it bottlenecks performance.
func GetFreePortOnHost(host string, firstPort uint32, portRange uint32) (uint32, error) {
@@ -52,16 +61,13 @@ func GetFreePortOnHost(host string, firstPort uint32, portRange uint32) (uint32,
func GetFreePortsOnHost(host string, firstPort uint32, portRange uint32, count int) ([]uint32, error) {
if Cache == nil {
- Init()
+ return nil, fmt.Errorf("redis cache not initialized")
}
if count <= 0 {
return []uint32{}, nil
}
- CacheMutex.Lock()
- defer CacheMutex.Unlock()
-
ctx := context.Background()
hostKey := utils.HostToKey(host)
@@ -93,6 +99,9 @@ func AssignFreePortOnHostToContainer(host string, containerId string, port uint3
}
func AssignPortsOnHostToContainer(host string, containerId string, ports []uint32) error {
+ if Cache == nil {
+ return fmt.Errorf("redis cache not initialized")
+ }
CacheMutex.Lock()
defer CacheMutex.Unlock()
@@ -129,11 +138,11 @@ func AssignPortsOnHostToContainer(host string, containerId string, ports []uint3
// GetContainerPortsOnHost gets all the assigned ports for a given container on a given host
func GetContainerPortsOnHost(host string, containerId string) ([]uint32, error) {
if Cache == nil {
- Init()
+ return nil, fmt.Errorf("redis cache not initialized")
}
- CacheMutex.Lock()
- defer CacheMutex.Unlock()
+ CacheMutex.RLock()
+ defer CacheMutex.RUnlock()
ctx := context.Background()
instanceKey := utils.ContainerToKey(host, containerId)
@@ -173,7 +182,7 @@ func redisValueToUint32(value interface{}) (uint32, error) {
func FreePortOnHost(host string, port uint32) error {
if Cache == nil {
- Init()
+ return fmt.Errorf("redis cache not initialized")
}
CacheMutex.Lock()
@@ -193,7 +202,7 @@ func FreePortOnHost(host string, port uint32) error {
// FreeContainerPortsOnHost frees all allocated host ports on a machine, at present occupied by a container
func FreeContainerPortsOnHost(host string, containerId string) error {
if Cache == nil {
- Init()
+ return fmt.Errorf("redis cache not initialized")
}
CacheMutex.Lock()
@@ -203,28 +212,12 @@ func FreeContainerPortsOnHost(host string, containerId string) error {
hostKey := utils.HostToKey(host)
instanceKey := utils.ContainerToKey(host, containerId)
- result, err := Cache.SMembers(ctx, instanceKey).Result()
+ result, err := Cache.Eval(ctx, freeContainerPortsScript, []string{hostKey, instanceKey}).Result()
if err != nil {
return err
}
-
- ports := make([]uint32, len(result))
- for i, portString := range result {
- port, err := strconv.ParseUint(portString, 10, 32)
- if err != nil {
- return err
- }
-
- ports[i] = uint32(port)
- Cache.SRem(ctx, instanceKey, port)
- }
-
- for _, port := range ports {
- _, err = Cache.SRem(ctx, hostKey, port).Result()
- if err != nil {
- return err
- }
+ if _, ok := result.([]interface{}); !ok {
+ return fmt.Errorf("unexpected Redis free-port result %T", result)
}
-
return nil
}
diff --git a/core/cache/ports_instance_test.go b/core/cache/ports_instance_test.go
index baefcb66..9700860a 100644
--- a/core/cache/ports_instance_test.go
+++ b/core/cache/ports_instance_test.go
@@ -34,14 +34,14 @@ func setupRedisIntegrationTest(t *testing.T) func() {
previousMutex := CacheMutex
previousConfig := cacheConfig
- CacheMutex = &sync.Mutex{}
+ CacheMutex = &sync.RWMutex{}
Cache = redis.NewClient(&redis.Options{
Addr: addr,
Username: os.Getenv("BEAST_TEST_REDIS_USER"),
Password: os.Getenv("BEAST_TEST_REDIS_PASSWORD"),
DB: db,
})
- cacheConfig.RedisConfig.DB = db
+ cacheConfig.DB = uint32(db)
ctx := context.Background()
if err := Cache.Ping(ctx).Err(); err != nil {
diff --git a/core/config/challenge.go b/core/config/challenge.go
index 47da75cb..0d37b0fb 100644
--- a/core/config/challenge.go
+++ b/core/config/challenge.go
@@ -3,7 +3,11 @@ package config
import (
"errors"
"fmt"
+ "net/mail"
+ "net/url"
+ "os"
"path/filepath"
+ "regexp"
"strings"
"github.com/sdslabs/beastv4/core"
@@ -16,6 +20,14 @@ import (
const SERVICE_CONTAINER_DEPS string = "xinetd"
const SERVICE_CHALL_RUN_CMD string = "xinetd -dontfork"
+var challengeNamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,63}$`)
+var environmentKeyPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
+var generatedPathPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]{0,255}$`)
+
+func IsValidChallengeName(name string) bool {
+ return challengeNamePattern.MatchString(name)
+}
+
// This is the beast challenge config file structure
// any other field specified in the file other than this structure
// will be ignored.
@@ -36,25 +48,24 @@ func (config *BeastChallengeConfig) PopulateDefaultValues() {
}
func (Author *Author) PopulateAuthor() {
- Author.Name = "AuthorName"
- Author.Email = "AuthorMail"
- Author.SSHKey = "AuthorPubKey"
+ Author.Name = "Author Name"
+ Author.Email = "author@example.com"
}
func (Metadata *ChallengeMetadata) PopulateChallengeMetadata() {
- Metadata.Name = "ChallengeName"
- Metadata.Type = "ChallengeType"
+ Metadata.Name = "challenge-name"
+ Metadata.Type = core.STATIC_CHALLENGE_TYPE_NAME
Metadata.DynamicFlag = false
- Metadata.Flag = "ChallengeFlag"
+ Metadata.Flag = "flag{replace-me}"
+ Metadata.Difficulty = "medium"
+ Metadata.Points = 100
}
func (Env *ChallengeEnv) PopulateChallengeEnv() {
Env.AptDeps = []string{}
Env.Ports = []uint32{}
Env.SetupScripts = []string{}
- Env.StaticContentDir = "StaticContentDir"
- Env.BaseImage = "ChallengeBase"
- Env.RunCmd = "RunCmd"
+ Env.StaticContentDir = core.PUBLIC
}
func (config *BeastChallengeConfig) ValidateRequiredFields(challdir string) error {
@@ -71,7 +82,18 @@ func (config *BeastChallengeConfig) ValidateRequiredFields(challdir string) erro
return err
}
- config.Resources.ValidateRequiredFields()
+ if err = config.Resources.ValidateRequiredFields(); err != nil {
+ return err
+ }
+ if config.Challenge.Env.DockerCompose != "" {
+ composePath, err := utils.ResolvePathWithin(challdir, config.Challenge.Env.DockerCompose)
+ if err != nil {
+ return err
+ }
+ if err := utils.ValidateComposeResources(composePath, config.Resources.Memory, config.Resources.PidsLimit, config.Resources.CPUsLimit); err != nil {
+ return fmt.Errorf("validate Compose resources: %w", err)
+ }
+ }
for _, maintainer := range config.Maintainers {
err = maintainer.ValidateRequiredFields()
@@ -102,7 +124,13 @@ func (config *Challenge) ValidateRequiredFields(challdir string) error {
return err
} else if staticChall {
log.Debugf("Challenge provided is a static challenge.")
- return nil
+ if config.Env.StaticContentDir == "" {
+ config.Env.StaticContentDir = core.PUBLIC
+ }
+ if err := validateChallengeDir(challdir, config.Env.StaticContentDir, "static_dir"); err != nil {
+ return err
+ }
+ return config.Metadata.ValidateAssets(challdir, config.Env.StaticContentDir)
}
err = config.Env.ValidateRequiredFields(config.Metadata.Type, challdir)
@@ -110,8 +138,7 @@ func (config *Challenge) ValidateRequiredFields(challdir string) error {
log.Debugf("Error while validating `ChallengeEnv`'s required fields : %s", err.Error())
return err
}
-
- return nil
+ return config.Metadata.ValidateAssets(challdir, config.Env.StaticContentDir)
}
// This contains challenge meta data
@@ -170,6 +197,29 @@ func (config *ChallengeMetadata) ValidateRequiredFields() (error, bool) {
if config.Name == "" || (config.Flag == "" && !config.DynamicFlag) {
return fmt.Errorf("name and flag required for the challenge"), false
}
+ if !IsValidChallengeName(config.Name) {
+ return fmt.Errorf("challenge name must match %s", challengeNamePattern.String()), false
+ }
+ if config.MaxPoints > 0 && config.MinPoints > config.MaxPoints {
+ return fmt.Errorf("minPoints cannot exceed maxPoints"), false
+ }
+ if config.MaxPoints == 0 && config.Points > 0 && config.MinPoints > config.Points {
+ return fmt.Errorf("minPoints cannot exceed points when maxPoints is omitted"), false
+ }
+ if config.MaxPoints > 0 && config.Points > config.MaxPoints {
+ return fmt.Errorf("points cannot exceed maxPoints"), false
+ }
+ for _, prerequisite := range config.PreReqs {
+ if !challengeNamePattern.MatchString(prerequisite) {
+ return fmt.Errorf("invalid prerequisite challenge name %q", prerequisite), false
+ }
+ }
+ for _, link := range config.AdditionalLinks {
+ parsed, err := url.ParseRequestURI(link)
+ if err != nil || (parsed.Scheme != "https" && parsed.Scheme != "http") || parsed.Host == "" {
+ return fmt.Errorf("invalid additional link %q", link), false
+ }
+ }
// Checks if fail solve limit is provided and is greater than 0
if config.MaxAttemptLimit < 0 {
@@ -199,6 +249,29 @@ func (config *ChallengeMetadata) ValidateRequiredFields() (error, bool) {
return fmt.Errorf("not a valid challenge type : %s", config.Type), false
}
+func (config *ChallengeMetadata) ValidateAssets(challengeDir, staticContentDir string) error {
+ if len(config.Assets) == 0 {
+ return nil
+ }
+ if staticContentDir == "" {
+ staticContentDir = core.PUBLIC
+ }
+ staticRoot, err := utils.ResolvePathWithin(challengeDir, staticContentDir)
+ if err != nil {
+ return fmt.Errorf("invalid static asset root: %w", err)
+ }
+ for _, asset := range config.Assets {
+ assetPath, err := utils.ResolvePathWithin(staticRoot, asset)
+ if err != nil {
+ return fmt.Errorf("invalid challenge asset %q: %w", asset, err)
+ }
+ if err := utils.ValidateFileExists(assetPath); err != nil {
+ return fmt.Errorf("invalid challenge asset %q: %w", asset, err)
+ }
+ }
+ return nil
+}
+
// This contains challenge specific properties which includes the following toml fields
//
// ```toml
@@ -294,6 +367,9 @@ func (config *ChallengeEnv) TrafficType() cr.TrafficType {
// GetDefaultPort returns the default port used by the challenge from the challenge environment
// configuration.
func (config *ChallengeEnv) GetDefaultPort() uint32 {
+ if config.DefaultPort != 0 {
+ return config.DefaultPort
+ }
ports := config.Ports
if len(ports) == 0 {
return 0
@@ -302,6 +378,44 @@ func (config *ChallengeEnv) GetDefaultPort() uint32 {
return ports[0]
}
+func validateChallengeFile(challengeDir, relativePath, field string) error {
+ resolvedPath, err := utils.ResolvePathWithin(challengeDir, relativePath)
+ if err != nil {
+ return fmt.Errorf("invalid %s %q: %w", field, relativePath, err)
+ }
+ if err := utils.ValidateFileExists(resolvedPath); err != nil {
+ return fmt.Errorf("invalid %s %q: %w", field, relativePath, err)
+ }
+ return nil
+}
+
+func validateChallengeDir(challengeDir, relativePath, field string) error {
+ resolvedPath, err := utils.ResolvePathWithin(challengeDir, relativePath)
+ if err != nil {
+ return fmt.Errorf("invalid %s %q: %w", field, relativePath, err)
+ }
+ if err := utils.ValidateDirExists(resolvedPath); err != nil {
+ return fmt.Errorf("invalid %s %q: %w", field, relativePath, err)
+ }
+ return nil
+}
+
+func validateGeneratedChallengeFile(challengeDir, relativePath, field string, setupScripts []string) error {
+ cleaned := filepath.ToSlash(filepath.Clean(relativePath))
+ if !generatedPathPattern.MatchString(cleaned) || cleaned == "." || strings.Contains(cleaned, "../") {
+ return fmt.Errorf("invalid %s path %q", field, relativePath)
+ }
+ if err := validateChallengeFile(challengeDir, cleaned, field); err == nil {
+ return nil
+ } else if _, statErr := os.Lstat(filepath.Join(challengeDir, filepath.FromSlash(cleaned))); !os.IsNotExist(statErr) {
+ return err
+ }
+ if len(setupScripts) == 0 {
+ return fmt.Errorf("%s %q does not exist and no setup script generates it", field, relativePath)
+ }
+ return nil
+}
+
// ValidateRequiredFields validates required fields for the Challenge environment configuration.
// This requires challenge type to be passed so that we can verfiy based on type
// of the challenge.
@@ -309,10 +423,7 @@ func (config *ChallengeEnv) ValidateRequiredFields(challType string, challdir st
// Validate port related stuff for the challenge environment configuration.
if config.StaticContentDir != "" {
- if filepath.IsAbs(config.StaticContentDir) {
- return fmt.Errorf("static content directory path should be relative to challenge directory root")
- }
- if err := utils.ValidateDirExists(filepath.Join(challdir, config.StaticContentDir)); err != nil {
+ if err := validateChallengeDir(challdir, config.StaticContentDir, "static_dir"); err != nil {
return err
}
}
@@ -322,11 +433,8 @@ func (config *ChallengeEnv) ValidateRequiredFields(challType string, challdir st
}
if config.DockerCompose != "" {
- if filepath.IsAbs(config.DockerCompose) {
- return fmt.Errorf("docker_compose path should be relative to challenge directory root")
- }
- if err := utils.ValidateFileExists(filepath.Join(challdir, config.DockerCompose)); err != nil {
- return fmt.Errorf("docker_compose file does not exist: %s", config.DockerCompose)
+ if err := validateChallengeFile(challdir, config.DockerCompose, "docker_compose"); err != nil {
+ return err
}
// Warn if other configuration fields are specified when docker_compose is provided
@@ -377,16 +485,23 @@ func (config *ChallengeEnv) ValidateRequiredFields(challType string, challdir st
if !utils.StringInSlice(config.BaseImage, Cfg.AllowedBaseImages) {
return fmt.Errorf("the base image: %s is not supported", config.BaseImage)
}
+ if config.DockerCtx != "" {
+ if err := validateChallengeFile(challdir, config.DockerCtx, "docker_context"); err != nil {
+ return err
+ }
+ }
+ if config.XinetdConf != "" {
+ if err := validateChallengeFile(challdir, config.XinetdConf, "xinetd_conf"); err != nil {
+ return err
+ }
+ }
if challType == core.SERVICE_CHALLENGE_TYPE_NAME {
// Challenge type is service.
// ServicePath must be relative.
if config.ServicePath != "" {
- if filepath.IsAbs(config.ServicePath) {
- return fmt.Errorf("for challenge type `services` service_path is a required variable, which should be relative path to executable")
- } else if err := utils.ValidateFileExists(filepath.Join(challdir, config.ServicePath)); err != nil {
- // Skip this, we might create service later too.
- log.Warnf("Service path file %s does not exist", config.ServicePath)
+ if err := validateGeneratedChallengeFile(challdir, config.ServicePath, "service_path", config.SetupScripts); err != nil {
+ return err
}
}
} else if strings.HasPrefix(challType, core.WEB_CHALLENGE_TYPE_NAME) {
@@ -394,35 +509,30 @@ func (config *ChallengeEnv) ValidateRequiredFields(challType string, challdir st
if config.WebRoot == "" && config.DockerCtx == "" && config.DockerCompose == "" {
return errors.New("web root can not be empty for web challenges without custom dockerfile or docker-compose")
} else if config.WebRoot != "" {
- if filepath.IsAbs(config.WebRoot) {
- return fmt.Errorf("web Root directory path should be relative to challenge directory root")
- } else if err := utils.ValidateDirExists(filepath.Join(challdir, config.WebRoot)); err != nil {
- return fmt.Errorf("web Root directory does not exist")
+ if err := validateChallengeDir(challdir, config.WebRoot, "web_root"); err != nil {
+ return err
}
}
}
for _, script := range config.SetupScripts {
- if filepath.IsAbs(script) {
- return fmt.Errorf("script path is absolute : %s", script)
- } else if err := utils.ValidateFileExists(filepath.Join(challdir, script)); err != nil {
- return fmt.Errorf("file %s does not exist", script)
+ if err := validateChallengeFile(challdir, script, "setup_scripts"); err != nil {
+ return err
}
}
for _, env := range config.EnvironmentVars {
- if filepath.IsAbs(env.Value) {
- return fmt.Errorf("environment Variable contains absolute path : %s", env.Value)
- } else if err := utils.ValidateFileExists(filepath.Join(challdir, env.Value)); err != nil {
- return fmt.Errorf("file %s does not exist", env.Value)
+ if !environmentKeyPattern.MatchString(env.Key) {
+ return fmt.Errorf("invalid environment variable key %q", env.Key)
+ }
+ if err := validateChallengeFile(challdir, env.Value, "environment variable value"); err != nil {
+ return err
}
}
if config.Entrypoint != "" {
- if filepath.IsAbs(config.Entrypoint) {
- return fmt.Errorf("entrypoint contains absolute path : %s", config.Entrypoint)
- } else if err := utils.ValidateFileExists(filepath.Join(challdir, config.Entrypoint)); err != nil {
- return fmt.Errorf("file %s does not exist", config.Entrypoint)
+ if err := validateChallengeFile(challdir, config.Entrypoint, "entrypoint"); err != nil {
+ return err
}
}
@@ -441,6 +551,16 @@ func (config *ChallengeEnv) ExtractPorts() error {
if len(config.Ports) > int(core.MAX_PORT_PER_CHALL) {
return fmt.Errorf("max ports allowed for challenge : %d given : %d", core.MAX_PORT_PER_CHALL, len(config.Ports))
}
+ seen := make(map[uint32]bool, len(config.Ports))
+ for _, port := range config.Ports {
+ if port == 0 || port > 65535 {
+ return fmt.Errorf("container port %d is outside 1-65535", port)
+ }
+ if seen[port] {
+ return fmt.Errorf("container port %d is duplicated", port)
+ }
+ seen[port] = true
+ }
if config.DefaultPort == 0 {
config.DefaultPort = config.Ports[0]
@@ -455,13 +575,20 @@ func (config *ChallengeEnv) ExtractPorts() error {
func (config *ChallengeEnv) ExtractPortsCompose(challdir string) error {
if config.DockerCompose != "" {
- portVariables, err := utils.ExtractPortsFromCompose(filepath.Join(challdir, config.DockerCompose))
+ composePath, err := utils.ResolvePathWithin(challdir, config.DockerCompose)
if err != nil {
- log.Warnf("failed to extract port variables from compose file with the following error : %s", err.Error())
+ return err
+ }
+ portVariables, err := utils.ExtractPortsFromCompose(composePath)
+ if err != nil {
+ return fmt.Errorf("extract compose ports: %w", err)
}
if len(portVariables) == 0 {
return errors.New("some port is required to be specified by the challenge")
}
+ if len(portVariables) > int(core.MAX_PORT_PER_CHALL) {
+ return fmt.Errorf("max ports allowed for challenge: %d given: %d", core.MAX_PORT_PER_CHALL, len(portVariables))
+ }
config.PortVariables = portVariables
if config.DefaultPortVar == "" {
@@ -480,8 +607,6 @@ func (config *ChallengeEnv) ExtractPortsCompose(challdir string) error {
//
// - Name - Name of the author of the challenge
// - Email - Email of the author
-// - SSHKey - Public SSH key for the challenge author, to give the access
-// to the challenge container.
//
// ```toml
// # Optional fields
@@ -489,17 +614,19 @@ func (config *ChallengeEnv) ExtractPortsCompose(challdir string) error {
//
// # Required Fields
// email = ""
-// ssh_key = "" # Public ssh Key of the author.
// ```
type Author struct {
- Name string `toml:"name"`
- Email string `toml:"email"`
- SSHKey string `toml:"ssh_key"`
+ Name string `toml:"name"`
+ Email string `toml:"email"`
}
func (config *Author) ValidateRequiredFields() error {
- if config.Email == "" || config.SSHKey == "" {
- return errors.New("Challenge `email` and `ssh_key` are required")
+ if config.Email == "" {
+ return errors.New("challenge author email is required")
+ }
+ address, err := mail.ParseAddress(config.Email)
+ if err != nil || address.Address != config.Email {
+ return fmt.Errorf("invalid challenge author email %q", config.Email)
}
if config.Name == "" {
@@ -521,7 +648,10 @@ type Resources struct {
CPUsLimit float32 `toml:"cpuslimit"`
}
-func (config *Resources) ValidateRequiredFields() {
+func (config *Resources) ValidateRequiredFields() error {
+ if Cfg == nil {
+ return errors.New("global configuration is not initialized")
+ }
if config.CPUShares <= 0 {
log.Debug("CPU shares not provided in configuration, using default.")
config.CPUShares = Cfg.CPUShares
@@ -541,4 +671,20 @@ func (config *Resources) ValidateRequiredFields() {
log.Debug("CPUsLimit not provided in configuration, using default.")
config.CPUsLimit = Cfg.CPUsLimit
}
+ if err := cr.ValidateResourceLimits(config.CPUShares, config.CPUsLimit, config.Memory, config.PidsLimit); err != nil {
+ return fmt.Errorf("invalid challenge resource limits: %w", err)
+ }
+ if config.CPUShares > Cfg.CPUShares {
+ return fmt.Errorf("cpu_shares %d exceeds global limit %d", config.CPUShares, Cfg.CPUShares)
+ }
+ if config.Memory > Cfg.Memory {
+ return fmt.Errorf("memory_limit %d exceeds global limit %d", config.Memory, Cfg.Memory)
+ }
+ if config.PidsLimit > Cfg.PidsLimit {
+ return fmt.Errorf("pids_limit %d exceeds global limit %d", config.PidsLimit, Cfg.PidsLimit)
+ }
+ if config.CPUsLimit > Cfg.CPUsLimit {
+ return fmt.Errorf("cpuslimit %.2f exceeds global limit %.2f", config.CPUsLimit, Cfg.CPUsLimit)
+ }
+ return nil
}
diff --git a/core/config/config.go b/core/config/config.go
index 98255850..fe3bced2 100644
--- a/core/config/config.go
+++ b/core/config/config.go
@@ -1,38 +1,35 @@
package config
import (
- "encoding/json"
+ "crypto/tls"
"errors"
"fmt"
+ "net"
"net/url"
"os"
"path/filepath"
"regexp"
+ "strconv"
"strings"
"time"
"github.com/sdslabs/beastv4/core"
+ "github.com/sdslabs/beastv4/pkg/cr"
"github.com/sdslabs/beastv4/utils"
- "github.com/BurntSushi/toml"
log "github.com/sirupsen/logrus"
)
+var configIdentifierPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
+var hostnamePattern = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251}[A-Za-z0-9])?$`)
+var scpGitURLPattern = regexp.MustCompile(`^[^@\s]+@[^:\s]+:[^\s]+$`)
+var gitBranchPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$`)
+
// This is the global beast configuration structure
//
// # An example of a config file
//
// ```toml
-// # Authorized key file used by ssh daemon running on the host
-// # This is used for forwarding ssh connection to docker containers, the
-// # access to a container is only given to the author, maintainers of challenge and admin.
-// authorized_keys_file = "/home/fristonio/.beast/beast_authorized_keys"
-//
-// # Directory which will contain all the autogenerated scripts by beast
-// # These scripts are the heart to above authorized keys file. Each entry in authorized
-// # keys file as a corresponding script which is executed during an SSH attempt.
-// scripts_dir = "/home/fristonio/.beast/scripts"
-//
// # Base OS image that beast allows the challenges to use.
// allowed_base_images = ["ubuntu:18.04", "ubuntu:16.04", "debian:jessie"]
//
@@ -110,10 +107,9 @@ import (
// host = "localhost"
// port = "5432"
// sslmode = "prefer"
+// sslrootcert = ""
// ```
type BeastConfig struct {
- AuthorizedKeysFile string `toml:"authorized_keys_file"`
- BeastScriptsDir string `toml:"scripts_dir"`
AllowedBaseImages []string `toml:"allowed_base_images"`
AvailableServers map[string]AvailableServer `toml:"available_servers"`
GitRemotes []GitRemote `toml:"remote"`
@@ -128,6 +124,7 @@ type BeastConfig struct {
RemoteSyncPeriod time.Duration `toml:"-"`
Rsp string `toml:"remote_sync_period"`
InstanceConfig InstanceConfig `toml:"instance_config"`
+ ServerConfig ServerConfig `toml:"server"`
CPUShares int64 `toml:"default_cpu_shares"`
Memory int64 `toml:"default_memory_limit"`
@@ -143,6 +140,60 @@ type InstanceConfig struct {
MaxInstancesPerUser int `toml:"max_instances_per_user"`
}
+type ServerConfig struct {
+ TLSCertFile string `toml:"tls_cert_file"`
+ TLSKeyFile string `toml:"tls_key_file"`
+ AllowedOrigins []string `toml:"allowed_origins"`
+}
+
+func (config *ServerConfig) Validate() error {
+ if err := validateAllowedOrigins(config.AllowedOrigins); err != nil {
+ return err
+ }
+ if config.TLSCertFile == "" || config.TLSKeyFile == "" {
+ return errors.New("server tls_cert_file and tls_key_file are required")
+ }
+ var err error
+ config.TLSCertFile, err = utils.ExpandHomePath(config.TLSCertFile)
+ if err != nil {
+ return err
+ }
+ config.TLSKeyFile, err = utils.ExpandHomePath(config.TLSKeyFile)
+ if err != nil {
+ return err
+ }
+ if err := utils.ValidateFileExists(config.TLSCertFile); err != nil {
+ return fmt.Errorf("invalid TLS certificate file: %w", err)
+ }
+ if err := utils.ValidateSecretFile(config.TLSKeyFile); err != nil {
+ return fmt.Errorf("invalid TLS private key file: %w", err)
+ }
+ if _, err := tls.LoadX509KeyPair(config.TLSCertFile, config.TLSKeyFile); err != nil {
+ return fmt.Errorf("load TLS certificate and key: %w", err)
+ }
+ return nil
+}
+
+func validateAllowedOrigins(origins []string) error {
+ seenOrigins := make(map[string]struct{}, len(origins))
+ for _, origin := range origins {
+ parsed, err := url.ParseRequestURI(origin)
+ if err != nil || parsed.Host == "" || parsed.User != nil || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" {
+ return fmt.Errorf("invalid CORS origin %q", origin)
+ }
+ hostname := parsed.Hostname()
+ loopback := hostname == "localhost" || net.ParseIP(hostname) != nil && net.ParseIP(hostname).IsLoopback()
+ if parsed.Scheme != "https" && !(parsed.Scheme == "http" && loopback) {
+ return fmt.Errorf("CORS origin must use HTTPS unless it is loopback: %q", origin)
+ }
+ if _, exists := seenOrigins[origin]; exists {
+ return fmt.Errorf("duplicate CORS origin %q", origin)
+ }
+ seenOrigins[origin] = struct{}{}
+ }
+ return nil
+}
+
func (config *InstanceConfig) Validate() {
if config.DefaultExpiration <= 0 {
config.DefaultExpiration = core.DEFAULT_MINIMUM_EXTEND_TIME
@@ -184,34 +235,6 @@ func ValidatePortRange(portRange string) error {
func (config *BeastConfig) ValidateConfig() error {
log.Debug("Validating BeastConfig structure")
- if config.AuthorizedKeysFile != "" {
- err := utils.CreateFileIfNotExist(config.AuthorizedKeysFile)
- if err != nil {
- log.Errorf("Error while creating authorized_keys file : %s", config.AuthorizedKeysFile)
- }
-
- config.AuthorizedKeysFile, err = filepath.Abs(config.AuthorizedKeysFile)
- if err != nil {
- return fmt.Errorf("error while getting absolute path : %s", err)
- }
- } else {
- defaultAuthKeyFile := filepath.Join(os.Getenv("HOME"), core.DEFAULT_AUTH_KEYS_FILE)
- log.Warnf("No authorized_keys file path provided, using default : %s", defaultAuthKeyFile)
- config.AuthorizedKeysFile = defaultAuthKeyFile
- }
-
- if config.BeastScriptsDir == "" {
- defaultBeastScriptDir := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_SCRIPTS_DIR)
- log.Warnf("no scripts directory provided for beast, using default : %s", defaultBeastScriptDir)
- config.BeastScriptsDir = defaultBeastScriptDir
- } else {
- err := utils.CreateIfNotExistDir(config.BeastScriptsDir)
- if err != nil {
- log.Error("Error while creating beast scripts directory")
- return err
- }
- }
-
err := config.PsqlConf.ValidatePsqlConfig()
if err != nil {
return fmt.Errorf("error while validating db config : %s", err)
@@ -221,6 +244,9 @@ func (config *BeastConfig) ValidateConfig() error {
if err != nil {
return fmt.Errorf("error while validating redis config : %s", err)
}
+ if err := config.CompetitionInfo.Validate(); err != nil {
+ return fmt.Errorf("validate competition info: %w", err)
+ }
if len(config.AvailableServers) == 0 {
log.Warn("No available servers provided for challenges. Using default localhost")
@@ -237,8 +263,8 @@ func (config *BeastConfig) ValidateConfig() error {
}
for name, server := range config.AvailableServers {
- if strings.Contains(name, ":") {
- return fmt.Errorf("server key %q contains invalid character ':'", name)
+ if !configIdentifierPattern.MatchString(name) {
+ return fmt.Errorf("server key %q is not a valid identifier", name)
}
server.Name = name
@@ -246,34 +272,39 @@ func (config *BeastConfig) ValidateConfig() error {
if server.Active {
err := server.ValidateServerConfig()
if err != nil {
- return fmt.Errorf("error while validating config : %s", server.Host)
+ return fmt.Errorf("validate server %q: %w", name, err)
}
}
}
- _, err = url.Parse(config.BeastStaticUrl)
-
- if err != nil {
- return fmt.Errorf("invalid beast static URL provided : %s", config.BeastStaticUrl)
+ if config.BeastStaticUrl != "" {
+ staticURL, err := url.ParseRequestURI(config.BeastStaticUrl)
+ if err != nil || (staticURL.Scheme != "http" && staticURL.Scheme != "https") || staticURL.Host == "" {
+ return fmt.Errorf("invalid beast static URL provided: %s", config.BeastStaticUrl)
+ }
}
if !utils.StringInSlice(core.DEFAULT_BASE_IMAGE, config.AllowedBaseImages) {
config.AllowedBaseImages = append(config.AllowedBaseImages, core.DEFAULT_BASE_IMAGE)
}
- if config.JWTSecret == "" {
- log.Error("The secret string is empty in beast config")
- return fmt.Errorf("invalid config")
+ if len(config.JWTSecret) < 32 {
+ return fmt.Errorf("jwt_secret must contain at least 32 bytes")
}
for _, gitRemote := range config.GitRemotes {
if gitRemote.Active {
err := gitRemote.ValidateGitConfig()
if err != nil {
- return fmt.Errorf("error while validating config : %s", gitRemote.RemoteName)
+ return fmt.Errorf("validate git remote %q: %w", gitRemote.RemoteName, err)
}
}
}
+ for index := range config.NotificationWebhooks {
+ if err := config.NotificationWebhooks[index].Validate(); err != nil {
+ return fmt.Errorf("validate notification webhook %d: %w", index+1, err)
+ }
+ }
if config.TickerFrequency <= 0 {
log.Debug("Time is not provided or is less than equal to zero so default time is taken")
@@ -312,12 +343,18 @@ func (config *BeastConfig) ValidateConfig() error {
log.Debug("Per container CPUsLimit Limit not provided using default value")
config.CPUsLimit = core.DEFAULT_CPU_LIMIT
}
+ if err := cr.ValidateResourceLimits(config.CPUShares, config.CPUsLimit, config.Memory, config.PidsLimit); err != nil {
+ return fmt.Errorf("invalid default container resource limits: %w", err)
+ }
if config.MailConfig.From == "" || config.MailConfig.Password == "" || config.MailConfig.SMTPHost == "" || config.MailConfig.SMTPPort == "" {
log.Warn("Mail configuration not provided, email notifications will not work")
}
config.InstanceConfig.Validate()
+ if err := config.ServerConfig.Validate(); err != nil {
+ return err
+ }
return nil
}
@@ -325,18 +362,19 @@ func (config *BeastConfig) ValidateConfig() error {
func (config *BeastConfig) UseLocalDockerDaemon(serverName string) bool {
server, ok := config.AvailableServers[serverName]
if !ok {
- return true
+ return false
}
return server.Host == core.LOCALHOST || server.Host == core.LOCALHOST_IP
}
type AvailableServer struct {
- Name string `toml:"-"`
- Host string `toml:"host"`
- Username string `toml:"username"`
- SSHKeyPath string `toml:"ssh_key_path"`
- Active bool `toml:"active"`
- PortRange string `toml:"port_range"`
+ Name string `toml:"-"`
+ Host string `toml:"host"`
+ Username string `toml:"username"`
+ SSHKeyPath string `toml:"ssh_key_path"`
+ KnownHostsFile string `toml:"known_hosts_file"`
+ Active bool `toml:"active"`
+ PortRange string `toml:"port_range"`
}
func (config *AvailableServer) ValidateServerConfig() error {
@@ -344,6 +382,9 @@ func (config *AvailableServer) ValidateServerConfig() error {
return fmt.Errorf("host is empty")
}
config.Host = strings.TrimSpace(config.Host)
+ if net.ParseIP(config.Host) == nil && !hostnamePattern.MatchString(config.Host) {
+ return fmt.Errorf("host %q is not a valid IP address or hostname", config.Host)
+ }
err := ValidatePortRange(config.PortRange)
if err != nil {
@@ -360,11 +401,25 @@ func (config *AvailableServer) ValidateServerConfig() error {
if config.SSHKeyPath == "" {
return fmt.Errorf("ssh_key_path is empty")
}
+ if config.KnownHostsFile == "" {
+ config.KnownHostsFile = filepath.Join(os.Getenv("HOME"), ".ssh", "known_hosts")
+ }
+ config.SSHKeyPath, err = utils.ExpandHomePath(config.SSHKeyPath)
+ if err != nil {
+ return err
+ }
+ config.KnownHostsFile, err = utils.ExpandHomePath(config.KnownHostsFile)
+ if err != nil {
+ return err
+ }
- err = utils.ValidateFileExists(config.SSHKeyPath)
+ err = utils.ValidateSecretFile(config.SSHKeyPath)
if err != nil {
return fmt.Errorf("provided ssh key file(%s) does not exists : %s", config.SSHKeyPath, err)
}
+ if err := utils.ValidateFileExists(config.KnownHostsFile); err != nil {
+ return fmt.Errorf("provided known_hosts file(%s) does not exist: %s", config.KnownHostsFile, err)
+ }
return nil
}
@@ -382,23 +437,30 @@ func (config *GitRemote) ValidateGitConfig() error {
log.Error("One of url, RemoteName or ssh_key is missing in the config")
return errors.New("git remote config not valid, config parameters missing")
}
-
- gitUrlRegexp, err := regexp.Compile(config.Url)
+ var err error
+ config.Secret, err = utils.ExpandHomePath(config.Secret)
if err != nil {
- eMsg := fmt.Errorf("error while compiling git url regex : %s", err)
- return eMsg
+ return err
}
- if !gitUrlRegexp.MatchString(config.Url) {
- return errors.New("the provided git url is not valid")
+ if !configIdentifierPattern.MatchString(config.RemoteName) {
+ return fmt.Errorf("git remote name %q is not a valid identifier", config.RemoteName)
}
-
if config.Branch == "" {
- log.Warnf("branch for git remote not provided, using %s", core.GIT_REMOTE_DEFAULT_BRANCH)
config.Branch = core.GIT_REMOTE_DEFAULT_BRANCH
}
+ if !gitBranchPattern.MatchString(config.Branch) || strings.Contains(config.Branch, "..") || strings.Contains(config.Branch, "@{") {
+ return fmt.Errorf("git branch %q is not valid", config.Branch)
+ }
+ validURL := scpGitURLPattern.MatchString(config.Url)
+ if parsed, err := url.Parse(config.Url); err == nil && parsed.Scheme == "ssh" && parsed.Host != "" && parsed.Path != "" {
+ validURL = true
+ }
+ if !validURL {
+ return errors.New("the provided git url is not valid")
+ }
- err = utils.ValidateFileExists(config.Secret)
+ err = utils.ValidateSecretFile(config.Secret)
log.Debugf("Using git ssh secret : %s", config.Secret)
if err != nil {
return fmt.Errorf("provided ssh key file(%s) does not exists : %s", config.Secret, err)
@@ -408,39 +470,114 @@ func (config *GitRemote) ValidateGitConfig() error {
}
type PsqlConfig struct {
- User string `toml:"user"`
- Password string `toml:"password"`
- Dbname string `toml:"dbname"`
- Host string `toml:"host"`
- Port string `toml:"port"`
- SslMode string `toml:"sslmode"`
+ User string `toml:"user"`
+ Password string `toml:"password"`
+ Dbname string `toml:"dbname"`
+ Host string `toml:"host"`
+ Port string `toml:"port"`
+ SslMode string `toml:"sslmode"`
+ SSLRootCert string `toml:"sslrootcert"`
}
type RedisConfig struct {
- User string `toml:"user"`
- Password string `toml:"password"`
- Host string `toml:"host"`
- Port string `toml:"port"`
- Db uint32 `toml:"db"`
+ User string `toml:"user"`
+ Password string `toml:"password"`
+ Host string `toml:"host"`
+ Port string `toml:"port"`
+ Db uint32 `toml:"db"`
+ TLS bool `toml:"tls"`
+ CAFile string `toml:"ca_file"`
+ ServerName string `toml:"server_name"`
}
func (config *PsqlConfig) ValidatePsqlConfig() error {
if config.User == "" || config.Password == "" || config.Dbname == "" || config.Host == "" || config.Port == "" {
- log.Error("One of username, password, dbname, hostname, port is missing in the config")
return errors.New("psql config not valid, config parameters missing")
}
+ if !configIdentifierPattern.MatchString(config.User) || !configIdentifierPattern.MatchString(config.Dbname) {
+ return errors.New("psql user and database names must be safe identifiers")
+ }
+ if err := validateServiceAddress(config.Host, config.Port); err != nil {
+ return fmt.Errorf("invalid psql address: %w", err)
+ }
if config.SslMode == "" {
- log.Warn("Ssl Mode not set. Disabling it.")
config.SslMode = "prefer"
}
+ validSSLModes := map[string]struct{}{
+ "disable": {}, "allow": {}, "prefer": {}, "require": {}, "verify-ca": {}, "verify-full": {},
+ }
+ if _, ok := validSSLModes[config.SslMode]; !ok {
+ return fmt.Errorf("unsupported psql sslmode %q", config.SslMode)
+ }
+ isLoopback := config.Host == "localhost"
+ if ip := net.ParseIP(config.Host); ip != nil {
+ isLoopback = ip.IsLoopback()
+ }
+ if !isLoopback && config.SslMode != "verify-full" {
+ return errors.New("psql sslmode verify-full is required for non-loopback connections")
+ }
+ if config.SslMode == "verify-ca" || config.SslMode == "verify-full" {
+ if config.SSLRootCert == "" {
+ return errors.New("psql sslrootcert is required when verifying TLS")
+ }
+ rootCert, err := utils.ExpandHomePath(config.SSLRootCert)
+ if err != nil {
+ return fmt.Errorf("expand psql root certificate: %w", err)
+ }
+ config.SSLRootCert = rootCert
+ if err := utils.ValidateFileExists(config.SSLRootCert); err != nil {
+ return fmt.Errorf("validate psql root certificate: %w", err)
+ }
+ } else if config.SSLRootCert != "" {
+ return errors.New("psql sslrootcert requires sslmode verify-ca or verify-full")
+ }
return nil
}
func (config *RedisConfig) ValidateRedisConfig() error {
- if config.Host == "" || config.Port == "" {
- log.Error("One of hostname or port is missing in the config")
+ if config.Password == "" || config.Host == "" || config.Port == "" {
return errors.New("redis config not valid, config parameters missing")
}
+ if config.User != "" && !configIdentifierPattern.MatchString(config.User) {
+ return errors.New("redis user must be a safe identifier")
+ }
+ if err := validateServiceAddress(config.Host, config.Port); err != nil {
+ return fmt.Errorf("invalid redis address: %w", err)
+ }
+ if !config.TLS {
+ ip := net.ParseIP(config.Host)
+ if config.Host != "localhost" && (ip == nil || !ip.IsLoopback()) {
+ return errors.New("TLS is required for non-loopback Redis connections")
+ }
+ if config.CAFile != "" || config.ServerName != "" {
+ return errors.New("Redis CA file and server name require TLS")
+ }
+ return nil
+ }
+ if config.ServerName != "" && net.ParseIP(config.ServerName) == nil && !hostnamePattern.MatchString(config.ServerName) {
+ return fmt.Errorf("invalid Redis TLS server name %q", config.ServerName)
+ }
+ if config.CAFile != "" {
+ caFile, err := utils.ExpandHomePath(config.CAFile)
+ if err != nil {
+ return fmt.Errorf("expand Redis CA file: %w", err)
+ }
+ config.CAFile = caFile
+ if err := utils.ValidateFileExists(config.CAFile); err != nil {
+ return fmt.Errorf("validate Redis CA file: %w", err)
+ }
+ }
+ return nil
+}
+
+func validateServiceAddress(host, port string) error {
+ if net.ParseIP(host) == nil && !hostnamePattern.MatchString(host) {
+ return fmt.Errorf("invalid host %q", host)
+ }
+ portNumber, err := strconv.ParseUint(port, 10, 16)
+ if err != nil || portNumber == 0 {
+ return fmt.Errorf("invalid port %q", port)
+ }
return nil
}
@@ -450,6 +587,35 @@ type NotificationWebhook struct {
Active bool `toml:"active"`
}
+func (webhook NotificationWebhook) Validate() error {
+ if !webhook.Active {
+ return nil
+ }
+ if len(webhook.URL) > 2048 {
+ return errors.New("webhook URL is too long")
+ }
+ parsed, err := url.Parse(webhook.URL)
+ if err != nil || parsed.Scheme != "https" || parsed.Hostname() == "" || parsed.User != nil || parsed.Fragment != "" {
+ return errors.New("active webhook must use a valid HTTPS URL without credentials or fragments")
+ }
+ if parsed.Port() != "" && parsed.Port() != "443" {
+ return errors.New("webhook URL may only use the default HTTPS port")
+ }
+ switch webhook.ServiceName {
+ case "slack":
+ if parsed.Hostname() != "hooks.slack.com" || !strings.HasPrefix(parsed.EscapedPath(), "/services/") {
+ return errors.New("Slack webhook must use hooks.slack.com/services")
+ }
+ case "discord":
+ if (parsed.Hostname() != "discord.com" && parsed.Hostname() != "discordapp.com") || !strings.HasPrefix(parsed.EscapedPath(), "/api/webhooks/") {
+ return errors.New("Discord webhook must use the official API webhook endpoint")
+ }
+ default:
+ return fmt.Errorf("unsupported notification service %q", webhook.ServiceName)
+ }
+ return nil
+}
+
type CompetitionInfo struct {
Name string `toml:"name"`
About string `toml:"about"`
@@ -461,60 +627,67 @@ type CompetitionInfo struct {
DynamicScore bool `toml:"dynamic_score"`
}
-type MailConfig struct {
- From string `toml:"from"`
- Password string `toml:"password"`
- SMTPHost string `toml:"smtpHost"`
- SMTPPort string `toml:"smtpPort"`
+func (config CompetitionInfo) Validate() error {
+ windowConfigured := config.StartingTime != "" || config.EndingTime != "" || config.TimeZone != ""
+ if !windowConfigured {
+ if config.DynamicScore {
+ return errors.New("dynamic scoring requires a competition time window")
+ }
+ return nil
+ }
+ _, _, err := config.ParseWindow()
+ return err
}
-func UpdateCompetitionInfo(competitionInfo *CompetitionInfo) error {
- configPath := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_CONFIG_FILE_NAME)
- var config BeastConfig
-
- err := utils.ValidateFileExists(configPath)
- if err != nil {
- return err
+func (config CompetitionInfo) ParseWindow() (time.Time, time.Time, error) {
+ if config.StartingTime == "" || config.EndingTime == "" || config.TimeZone == "" {
+ return time.Time{}, time.Time{}, errors.New("starting_time, ending_time, and timezone must be configured together")
}
-
- _, err = toml.DecodeFile(configPath, &config)
+ locationName := strings.TrimSpace(strings.SplitN(config.TimeZone, ":", 2)[0])
+ location, err := time.LoadLocation(locationName)
if err != nil {
- return err
+ return time.Time{}, time.Time{}, fmt.Errorf("load competition timezone %q: %w", locationName, err)
}
-
- config.CompetitionInfo = *competitionInfo
-
- configFile, err := os.Create(configPath)
+ start, err := parseCompetitionTimestamp(config.StartingTime, location)
if err != nil {
- return err
+ return time.Time{}, time.Time{}, fmt.Errorf("parse starting_time: %w", err)
}
-
- if err := toml.NewEncoder(configFile).Encode(config); err != nil {
- return err
+ end, err := parseCompetitionTimestamp(config.EndingTime, location)
+ if err != nil {
+ return time.Time{}, time.Time{}, fmt.Errorf("parse ending_time: %w", err)
}
-
- if err := configFile.Close(); err != nil {
- return err
+ if !end.After(start) {
+ return time.Time{}, time.Time{}, errors.New("ending_time must be after starting_time")
}
- return err
+ return start, end, nil
}
-func GetCompetitionInfo() (CompetitionInfo, error) {
- configPath := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_CONFIG_FILE_NAME)
- var config BeastConfig
- var competitionInfo CompetitionInfo
-
- err := utils.ValidateFileExists(configPath)
- if err != nil {
- return competitionInfo, err
+func parseCompetitionTimestamp(value string, location *time.Location) (time.Time, error) {
+ parts := strings.SplitN(value, ",", 3)
+ timeFields := strings.Fields(parts[0])
+ if len(parts) < 2 || len(timeFields) == 0 {
+ return time.Time{}, fmt.Errorf("invalid timestamp %q", value)
}
-
- _, err = toml.DecodeFile(configPath, &config)
+ dateAndTime := strings.TrimSpace(parts[1]) + " " + timeFields[0]
+ parsed, err := time.ParseInLocation("2 January 2006 15:04:05", dateAndTime, location)
if err != nil {
- return competitionInfo, err
+ return time.Time{}, err
}
+ return parsed, nil
+}
- return config.CompetitionInfo, nil
+type MailConfig struct {
+ From string `toml:"from"`
+ Password string `toml:"password"`
+ SMTPHost string `toml:"smtpHost"`
+ SMTPPort string `toml:"smtpPort"`
+}
+
+func GetCompetitionInfo() (CompetitionInfo, error) {
+ if Cfg == nil {
+ return CompetitionInfo{}, errors.New("beast config is not initialized")
+ }
+ return Cfg.CompetitionInfo, nil
}
// From the path of the config file provided as an arguement this function
@@ -524,18 +697,28 @@ func GetCompetitionInfo() (CompetitionInfo, error) {
func LoadBeastConfig(configPath string) (BeastConfig, error) {
var config BeastConfig
- err := utils.ValidateFileExists(configPath)
+ info, err := os.Lstat(configPath)
if err != nil {
return config, err
}
+ if info.Mode()&os.ModeSymlink != 0 {
+ return config, fmt.Errorf("global config must not be a symbolic link: %s", configPath)
+ }
+ if !info.Mode().IsRegular() {
+ return config, fmt.Errorf("global config is not a regular file: %s", configPath)
+ }
+ if info.Mode().Perm() != 0600 {
+ return config, fmt.Errorf("global config permissions must be 0600, got %04o", info.Mode().Perm())
+ }
- _, err = toml.DecodeFile(configPath, &config)
+ err = utils.ValidateFileExists(configPath)
if err != nil {
return config, err
}
- prettyJSON, _ := json.MarshalIndent(config, "", " ")
- log.Debugf("Parsed beast global config file is : %s", string(prettyJSON))
+ if err = decodeTOMLFileStrict(configPath, &config); err != nil {
+ return config, err
+ }
err = config.ValidateConfig()
if err != nil {
@@ -547,40 +730,21 @@ func LoadBeastConfig(configPath string) (BeastConfig, error) {
}
var Cfg *BeastConfig
-var SkipAuthorization bool
var NoCache bool
-// InitConfig loads the config from the global config file and populate
-// the Cfg global variable used everywhere else.
-func InitConfig() {
+// InitConfig loads the config from the global config file and populates Cfg.
+func InitConfig() error {
log.Info("Loading up beast configuration.")
if Cfg != nil {
- log.Warn("Config is already initialized, reinitilize/reload using ReloadBeastConfig method")
- return
- }
- configPath := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_CONFIG_FILE_NAME)
- cfg, err := LoadBeastConfig(configPath)
-
- if err != nil {
- log.Errorf("Error while loading the beast global config : %s", err)
- os.Exit(1)
+ log.Warn("Config is already initialized; restart Beast to load configuration changes")
+ return nil
}
-
- log.Debugf("CONFIG LOAD: New Config : %v", cfg)
- Cfg = &cfg
-}
-
-// ReloadBeastConfig reloads the beast configuration and reinitializes the Cfg global
-// variable.
-func ReloadBeastConfig() error {
configPath := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_CONFIG_FILE_NAME)
cfg, err := LoadBeastConfig(configPath)
-
if err != nil {
- return fmt.Errorf("error while loading beast config: %s", err)
+ return fmt.Errorf("load Beast global config: %w", err)
}
Cfg = &cfg
- log.Debugf("CONFIG LOAD: New Config : %v", cfg)
return nil
}
diff --git a/core/config/examples_test.go b/core/config/examples_test.go
new file mode 100644
index 00000000..3014e217
--- /dev/null
+++ b/core/config/examples_test.go
@@ -0,0 +1,44 @@
+package config
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestMaintainedExamplesValidate(t *testing.T) {
+ previous := Cfg
+ Cfg = &BeastConfig{
+ AllowedBaseImages: []string{"ubuntu:24.04", "debian:bookworm"},
+ CPUShares: 1024,
+ CPUsLimit: 1,
+ Memory: 1 << 30,
+ PidsLimit: 256,
+ }
+ defer func() { Cfg = previous }()
+
+ examplesRoot := filepath.Join("..", "..", "_examples")
+ entries, err := os.ReadDir(examplesRoot)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, entry := range entries {
+ if !entry.IsDir() {
+ continue
+ }
+ challengeDir := filepath.Join(examplesRoot, entry.Name())
+ configPath := filepath.Join(challengeDir, "beast.toml")
+ if _, err := os.Stat(configPath); os.IsNotExist(err) {
+ continue
+ }
+ t.Run(entry.Name(), func(t *testing.T) {
+ configuration, err := LoadChallengeConfig(configPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := configuration.ValidateRequiredFields(challengeDir); err != nil {
+ t.Fatal(err)
+ }
+ })
+ }
+}
diff --git a/core/config/init_test.go b/core/config/init_test.go
new file mode 100644
index 00000000..842f0844
--- /dev/null
+++ b/core/config/init_test.go
@@ -0,0 +1,25 @@
+package config
+
+import (
+ "testing"
+
+ "github.com/sdslabs/beastv4/core"
+)
+
+func TestInitConfigReturnsLoadError(t *testing.T) {
+ previousCfg := Cfg
+ previousGlobalDir := core.BEAST_GLOBAL_DIR
+ Cfg = nil
+ core.BEAST_GLOBAL_DIR = t.TempDir()
+ t.Cleanup(func() {
+ Cfg = previousCfg
+ core.BEAST_GLOBAL_DIR = previousGlobalDir
+ })
+
+ if err := InitConfig(); err == nil {
+ t.Fatal("expected missing configuration error")
+ }
+ if Cfg != nil {
+ t.Fatal("configuration was initialized after a load error")
+ }
+}
diff --git a/core/config/utils.go b/core/config/utils.go
index 94157e67..1cd886e5 100644
--- a/core/config/utils.go
+++ b/core/config/utils.go
@@ -1,13 +1,47 @@
package config
import (
+ "fmt"
+ "sort"
"strings"
+ "github.com/BurntSushi/toml"
"github.com/sdslabs/beastv4/core"
)
+func decodeTOMLFileStrict(path string, target interface{}) error {
+ metadata, err := toml.DecodeFile(path, target)
+ if err != nil {
+ return err
+ }
+
+ undecoded := metadata.Undecoded()
+ if len(undecoded) == 0 {
+ return nil
+ }
+
+ keys := make([]string, 0, len(undecoded))
+ for _, key := range undecoded {
+ keys = append(keys, key.String())
+ }
+ sort.Strings(keys)
+ return fmt.Errorf("unknown configuration keys: %s", strings.Join(keys, ", "))
+}
+
+func LoadChallengeConfig(path string) (BeastChallengeConfig, error) {
+ var config BeastChallengeConfig
+ if err := decodeTOMLFileStrict(path, &config); err != nil {
+ return BeastChallengeConfig{}, err
+ }
+ return config, nil
+}
+
func GetAvailableChallengeTypes() []string {
- types := core.AVAILABLE_CHALLENGE_TYPES
+ types := append([]string(nil), core.AVAILABLE_CHALLENGE_TYPES...)
+ seen := make(map[string]bool, len(types))
+ for _, challengeType := range types {
+ seen[challengeType] = true
+ }
// Extract all the web challenges type.
for k := range core.DockerBaseImageForWebChall {
@@ -15,10 +49,14 @@ func GetAvailableChallengeTypes() []string {
for k2 := range core.DockerBaseImageForWebChall[k][k1] {
newType := "web:" + k + ":" + k1 + ":" + k2
newType = strings.TrimRight(strings.Replace(newType, "default", "", -1), ":")
- types = append(types, newType)
+ if !seen[newType] {
+ types = append(types, newType)
+ seen[newType] = true
+ }
}
}
}
+ sort.Strings(types)
return types
}
diff --git a/core/config/utils_test.go b/core/config/utils_test.go
new file mode 100644
index 00000000..63a8e338
--- /dev/null
+++ b/core/config/utils_test.go
@@ -0,0 +1,415 @@
+package config
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/sdslabs/beastv4/core"
+)
+
+func TestLoadChallengeConfigRejectsUnknownKeys(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "beast.toml")
+ data := []byte("[author]\nemail = \"author@example.com\"\nunknown = true\n")
+ if err := os.WriteFile(path, data, 0600); err != nil {
+ t.Fatal(err)
+ }
+
+ _, err := LoadChallengeConfig(path)
+ if err == nil || !strings.Contains(err.Error(), "author.unknown") {
+ t.Fatalf("expected unknown key error, got %v", err)
+ }
+}
+
+func TestLoadChallengeConfigAcceptsKnownKeys(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "beast.toml")
+ data := []byte("[author]\nemail = \"author@example.com\"\n")
+ if err := os.WriteFile(path, data, 0600); err != nil {
+ t.Fatal(err)
+ }
+
+ config, err := LoadChallengeConfig(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if config.Author.Email != "author@example.com" {
+ t.Fatalf("unexpected author email %q", config.Author.Email)
+ }
+}
+
+func TestChallengeEnvRejectsEscapingPaths(t *testing.T) {
+ parent := t.TempDir()
+ challengeDir := filepath.Join(parent, "challenge")
+ if err := os.Mkdir(challengeDir, 0700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(parent, "outside"), nil, 0600); err != nil {
+ t.Fatal(err)
+ }
+
+ env := ChallengeEnv{StaticContentDir: "../outside"}
+ err := env.ValidateRequiredFields("static", challengeDir)
+ if err == nil || !strings.Contains(err.Error(), "escapes root") {
+ t.Fatalf("expected escaping path error, got %v", err)
+ }
+}
+
+func TestChallengeEnvRejectsEscapingComposeSymlink(t *testing.T) {
+ parent := t.TempDir()
+ challengeDir := filepath.Join(parent, "challenge")
+ if err := os.Mkdir(challengeDir, 0700); err != nil {
+ t.Fatal(err)
+ }
+ outside := filepath.Join(parent, "compose.yml")
+ if err := os.WriteFile(outside, []byte("services: {}\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(outside, filepath.Join(challengeDir, "compose.yml")); err != nil {
+ t.Fatal(err)
+ }
+
+ env := ChallengeEnv{DockerCompose: "compose.yml"}
+ err := env.ValidateRequiredFields("bare", challengeDir)
+ if err == nil || !strings.Contains(err.Error(), "escapes root") {
+ t.Fatalf("expected escaping symlink error, got %v", err)
+ }
+}
+
+func TestChallengeMetadataRejectsUnsafeNames(t *testing.T) {
+ tests := []string{"../../escape", "name; touch pwned", "UPPERCASE", strings.Repeat("a", 65)}
+ for _, name := range tests {
+ t.Run(name, func(t *testing.T) {
+ metadata := ChallengeMetadata{Name: name, Flag: "flag", Type: "static"}
+ err, _ := metadata.ValidateRequiredFields()
+ if err == nil || !strings.Contains(err.Error(), "must match") {
+ t.Fatalf("expected unsafe name error, got %v", err)
+ }
+ })
+ }
+}
+
+func TestLoadBeastConfigRejectsInsecurePermissions(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "config.toml")
+ if err := os.WriteFile(path, nil, 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ _, err := LoadBeastConfig(path)
+ if err == nil || !strings.Contains(err.Error(), "must be 0600") {
+ t.Fatalf("expected permissions error, got %v", err)
+ }
+}
+
+func TestLoadBeastConfigRejectsSymlink(t *testing.T) {
+ dir := t.TempDir()
+ target := filepath.Join(dir, "target.toml")
+ if err := os.WriteFile(target, nil, 0600); err != nil {
+ t.Fatal(err)
+ }
+ path := filepath.Join(dir, "config.toml")
+ if err := os.Symlink(target, path); err != nil {
+ t.Fatal(err)
+ }
+
+ _, err := LoadBeastConfig(path)
+ if err == nil || !strings.Contains(err.Error(), "must not be a symbolic link") {
+ t.Fatalf("expected symlink error, got %v", err)
+ }
+}
+
+func TestResourcesCannotExceedGlobalLimits(t *testing.T) {
+ previous := Cfg
+ Cfg = &BeastConfig{CPUShares: 100, Memory: 8 << 20, PidsLimit: 10, CPUsLimit: 1}
+ defer func() { Cfg = previous }()
+
+ tests := []Resources{
+ {CPUShares: 101},
+ {Memory: (8 << 20) + 1},
+ {PidsLimit: 11},
+ {CPUsLimit: 1.1},
+ }
+ for _, resources := range tests {
+ if err := resources.ValidateRequiredFields(); err == nil {
+ t.Fatalf("expected global limit error for %+v", resources)
+ }
+ }
+}
+
+func TestResourcesUseGlobalDefaults(t *testing.T) {
+ previous := Cfg
+ Cfg = &BeastConfig{CPUShares: 100, Memory: 8 << 20, PidsLimit: 10, CPUsLimit: 1}
+ defer func() { Cfg = previous }()
+
+ resources := Resources{}
+ if err := resources.ValidateRequiredFields(); err != nil {
+ t.Fatal(err)
+ }
+ if resources.CPUShares != 100 || resources.Memory != 8<<20 || resources.PidsLimit != 10 || resources.CPUsLimit != 1 {
+ t.Fatalf("unexpected defaults: %+v", resources)
+ }
+}
+
+func TestServerConfigRequiresTLSFiles(t *testing.T) {
+ if err := (&ServerConfig{}).Validate(); err == nil || !strings.Contains(err.Error(), "required") {
+ t.Fatalf("expected required TLS files error, got %v", err)
+ }
+}
+
+func TestServerConfigExpandsHomePaths(t *testing.T) {
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+ server := ServerConfig{TLSCertFile: "$HOME/cert.pem", TLSKeyFile: "${HOME}/key.pem"}
+ if err := server.Validate(); err == nil {
+ t.Fatal("expected missing certificate error")
+ }
+ if server.TLSCertFile != filepath.Join(home, "cert.pem") || server.TLSKeyFile != filepath.Join(home, "key.pem") {
+ t.Fatalf("paths were not expanded: %+v", server)
+ }
+}
+
+func TestExampleGlobalConfigHasNoUnknownKeys(t *testing.T) {
+ var config BeastConfig
+ path := filepath.Join("..", "..", "_examples", "example.config.toml")
+ if err := decodeTOMLFileStrict(path, &config); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestNotificationWebhookValidation(t *testing.T) {
+ valid := []NotificationWebhook{
+ {Active: true, ServiceName: "slack", URL: "https://hooks.slack.com/services/a/b/c"},
+ {Active: true, ServiceName: "discord", URL: "https://discord.com/api/webhooks/1/token"},
+ }
+ for _, webhook := range valid {
+ if err := webhook.Validate(); err != nil {
+ t.Fatalf("expected valid webhook: %v", err)
+ }
+ }
+
+ invalid := []NotificationWebhook{
+ {Active: true, ServiceName: "slack", URL: "http://hooks.slack.com/services/a/b/c"},
+ {Active: true, ServiceName: "slack", URL: "https://127.0.0.1/services/a"},
+ {Active: true, ServiceName: "discord", URL: "https://discord.com.evil.test/api/webhooks/1/token"},
+ {Active: true, ServiceName: "custom", URL: "https://example.com/hook"},
+ }
+ for _, webhook := range invalid {
+ if err := webhook.Validate(); err == nil {
+ t.Fatalf("expected invalid webhook to fail: %+v", webhook)
+ }
+ }
+}
+
+func TestExampleChallengeConfigsHaveNoUnknownKeys(t *testing.T) {
+ paths, err := filepath.Glob(filepath.Join("..", "..", "_examples", "*", "beast.toml"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(paths) == 0 {
+ t.Fatal("no example challenge configs found")
+ }
+ for _, path := range paths {
+ t.Run(filepath.Base(filepath.Dir(path)), func(t *testing.T) {
+ if _, err := LoadChallengeConfig(path); err != nil {
+ t.Fatal(err)
+ }
+ })
+ }
+}
+
+func TestGetAvailableChallengeTypesIsStable(t *testing.T) {
+ first := GetAvailableChallengeTypes()
+ second := GetAvailableChallengeTypes()
+ if len(first) != len(second) {
+ t.Fatalf("challenge types grew between calls: %d then %d", len(first), len(second))
+ }
+ seen := make(map[string]bool, len(first))
+ for _, challengeType := range first {
+ if seen[challengeType] {
+ t.Fatalf("duplicate challenge type %q", challengeType)
+ }
+ seen[challengeType] = true
+ }
+}
+
+func TestChallengeMetadataRejectsInvalidScoringAndLinks(t *testing.T) {
+ tests := []ChallengeMetadata{
+ {Name: "challenge", Flag: "flag", Type: "static", MinPoints: 200, MaxPoints: 100},
+ {Name: "challenge", Flag: "flag", Type: "static", Points: 100, MinPoints: 200},
+ {Name: "challenge", Flag: "flag", Type: "static", Points: 200, MaxPoints: 100},
+ {Name: "challenge", Flag: "flag", Type: "static", PreReqs: []string{"../escape"}},
+ {Name: "challenge", Flag: "flag", Type: "static", AdditionalLinks: []string{"javascript:alert(1)"}},
+ }
+ for _, metadata := range tests {
+ if err, _ := metadata.ValidateRequiredFields(); err == nil {
+ t.Fatalf("expected invalid metadata error: %+v", metadata)
+ }
+ }
+}
+
+func TestChallengeEnvRejectsInvalidPortsAndEnvironmentKeys(t *testing.T) {
+ for _, ports := range [][]uint32{{0}, {80, 80}, {65536}} {
+ env := ChallengeEnv{Ports: ports}
+ if err := env.ExtractPorts(); err == nil {
+ t.Fatalf("expected invalid ports error: %v", ports)
+ }
+ }
+
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, "value"), nil, 0600); err != nil {
+ t.Fatal(err)
+ }
+ previous := Cfg
+ Cfg = &BeastConfig{AllowedBaseImages: []string{core.DEFAULT_BASE_IMAGE}}
+ defer func() { Cfg = previous }()
+ env := ChallengeEnv{
+ Ports: []uint32{8080},
+ RunCmd: "true",
+ EnvironmentVars: []EnvironmentVar{{Key: "BAD-NAME", Value: "value"}},
+ }
+ if err := env.ValidateRequiredFields("bare", dir); err == nil || !strings.Contains(err.Error(), "environment variable key") {
+ t.Fatalf("expected invalid environment key error, got %v", err)
+ }
+}
+
+func TestAuthorRequiresCanonicalEmail(t *testing.T) {
+ for _, email := range []string{"not-an-email", "Author "} {
+ author := Author{Email: email}
+ if err := author.ValidateRequiredFields(); err == nil {
+ t.Fatalf("expected invalid email error for %q", email)
+ }
+ }
+}
+
+func TestChallengeAssetsStayInsideStaticRoot(t *testing.T) {
+ parent := t.TempDir()
+ challenge := filepath.Join(parent, "challenge")
+ static := filepath.Join(challenge, "static")
+ if err := os.MkdirAll(static, 0700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(parent, "secret"), nil, 0600); err != nil {
+ t.Fatal(err)
+ }
+ metadata := ChallengeMetadata{Assets: []string{"../../secret"}}
+ if err := metadata.ValidateAssets(challenge, "static"); err == nil || !strings.Contains(err.Error(), "escapes root") {
+ t.Fatalf("expected escaping asset error, got %v", err)
+ }
+}
+
+func TestGetDefaultPortHonorsConfiguredPort(t *testing.T) {
+ env := ChallengeEnv{Ports: []uint32{8080, 9000}, DefaultPort: 9000}
+ if got := env.GetDefaultPort(); got != 9000 {
+ t.Fatalf("GetDefaultPort() = %d, want 9000", got)
+ }
+}
+
+func TestUnknownServerDoesNotUseLocalDocker(t *testing.T) {
+ config := BeastConfig{AvailableServers: map[string]AvailableServer{
+ "localhost": {Host: "localhost", Active: true},
+ }}
+ if config.UseLocalDockerDaemon("missing") {
+ t.Fatal("unknown server silently selected the local Docker daemon")
+ }
+}
+
+func TestGitRemoteRejectsUnsafeIdentifiersAndURLs(t *testing.T) {
+ tests := []GitRemote{
+ {Url: "git@example.com:repo.git", RemoteName: "../../escape", Branch: "main", Secret: "key"},
+ {Url: "git@example.com:repo.git", RemoteName: "origin", Branch: "../main", Secret: "key"},
+ {Url: "https://example.com/repo.git", RemoteName: "origin", Branch: "main", Secret: "key"},
+ }
+ for _, remote := range tests {
+ if err := remote.ValidateGitConfig(); err == nil {
+ t.Fatalf("expected unsafe git remote error: %+v", remote)
+ }
+ }
+}
+
+func TestServerRejectsInvalidHostname(t *testing.T) {
+ server := AvailableServer{Host: "host;id", PortRange: "10000:20000", Active: true}
+ if err := server.ValidateServerConfig(); err == nil {
+ t.Fatal("expected invalid hostname error")
+ }
+}
+
+func TestDataStoreConfigRejectsUnsafeValues(t *testing.T) {
+ psql := PsqlConfig{User: "beast", Password: "secret", Dbname: "../../escape", Host: "localhost", Port: "5432", SslMode: "prefer"}
+ if err := psql.ValidatePsqlConfig(); err == nil {
+ t.Fatal("expected unsafe database name error")
+ }
+ psql = PsqlConfig{User: "beast", Password: "secret", Dbname: "beast", Host: "localhost", Port: "5432", SslMode: "invalid"}
+ if err := psql.ValidatePsqlConfig(); err == nil {
+ t.Fatal("expected invalid sslmode error")
+ }
+ redis := RedisConfig{User: "beast", Host: "host;id", Port: "6379", Password: "secret"}
+ if err := redis.ValidateRedisConfig(); err == nil {
+ t.Fatal("expected invalid Redis host error")
+ }
+ redis = RedisConfig{User: "beast", Host: "localhost", Port: "0", Password: "secret"}
+ if err := redis.ValidateRedisConfig(); err == nil {
+ t.Fatal("expected invalid Redis port error")
+ }
+}
+
+func TestRemoteRedisRequiresTLS(t *testing.T) {
+ redis := RedisConfig{User: "beast", Host: "redis.example.com", Port: "6379", Password: "secret"}
+ if err := redis.ValidateRedisConfig(); err == nil || !strings.Contains(err.Error(), "TLS is required") {
+ t.Fatalf("expected remote plaintext Redis rejection, got %v", err)
+ }
+ redis.TLS = true
+ if err := redis.ValidateRedisConfig(); err != nil {
+ t.Fatalf("expected remote TLS Redis config to pass: %v", err)
+ }
+}
+
+func TestRemotePostgresRequiresVerifiedTLS(t *testing.T) {
+ psql := PsqlConfig{User: "beast", Password: "secret", Dbname: "beast", Host: "db.example.com", Port: "5432", SslMode: "require"}
+ if err := psql.ValidatePsqlConfig(); err == nil || !strings.Contains(err.Error(), "verify-full") {
+ t.Fatalf("expected remote TLS verification rejection, got %v", err)
+ }
+ psql.SslMode = "verify-full"
+ if err := psql.ValidatePsqlConfig(); err == nil || !strings.Contains(err.Error(), "sslrootcert") {
+ t.Fatalf("expected missing root certificate rejection, got %v", err)
+ }
+ rootCert := filepath.Join(t.TempDir(), "root.pem")
+ if err := os.WriteFile(rootCert, []byte("test"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ psql.SSLRootCert = rootCert
+ if err := psql.ValidatePsqlConfig(); err != nil {
+ t.Fatalf("expected verified remote PostgreSQL config to pass: %v", err)
+ }
+}
+
+func TestCompetitionInfoValidatesTimeWindow(t *testing.T) {
+ info := CompetitionInfo{
+ StartingTime: "00:00:00 UTC: +05:30, 1 January 2030, Tuesday",
+ EndingTime: "23:59:59 UTC: +05:30, 2 January 2030, Wednesday",
+ TimeZone: "Asia/Calcutta: UTC +05:30",
+ }
+ start, end, err := info.ParseWindow()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !end.After(start) {
+ t.Fatal("parsed competition window is not ordered")
+ }
+
+ info.EndingTime = "malformed"
+ if err := info.Validate(); err == nil {
+ t.Fatal("expected malformed competition time error")
+ }
+}
+
+func TestServerRejectsUnsafeCORSOrigins(t *testing.T) {
+ if err := validateAllowedOrigins([]string{"*"}); err == nil {
+ t.Fatal("expected wildcard CORS origin error")
+ }
+ if err := validateAllowedOrigins([]string{"http://example.com"}); err == nil {
+ t.Fatal("expected insecure CORS origin error")
+ }
+ if err := validateAllowedOrigins([]string{"https://example.com", "http://localhost:3000"}); err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/core/constants.go b/core/constants.go
index a46cf279..11046026 100644
--- a/core/constants.go
+++ b/core/constants.go
@@ -8,9 +8,8 @@ import (
var (
// BEAST_GLOBAL_DIR should always be used only on local deployment
- BEAST_GLOBAL_DIR = filepath.Join(os.Getenv("HOME"), ".beast")
- AUTHORIZED_KEYS_FILE = filepath.Join(os.Getenv("HOME"), ".ssh", "authorized_keys")
- BEAST_TEMP_DIR = filepath.Join(os.TempDir(), "beast")
+ BEAST_GLOBAL_DIR = filepath.Join(os.Getenv("HOME"), ".beast")
+ BEAST_TEMP_DIR = filepath.Join(os.TempDir(), "beast")
)
const ( //names
@@ -36,7 +35,7 @@ const ( //names
DELIMITER string = "::::"
LOCALHOST string = "localhost"
LOCALHOST_IP string = "127.0.0.1"
- BEAST_REMOTE_GLOBAL_DIR string = "~/.beast" // This should always be used for remote only.
+ BEAST_REMOTE_GLOBAL_DIR string = ".beast" // Relative to the remote SSH user's home directory.
DOCKER_PID string = "/var/run/docker.pid"
BEAST_GRAPH_CACHE string = "graph_cache.json"
BEAST_LEADERBOARD_CACHE string = "leaderboard.json"
@@ -47,9 +46,7 @@ const ( //names
const ( //paths
BEAST_DOCKER_CHALLENGE_DIR string = "/challenge"
BEAST_CHALLENGE_LOGS_DIR string = "logs"
- DEFAULT_AUTH_KEYS_FILE string = "beast_authorized_keys"
BEAST_STAGING_DIR string = "staging"
- BEAST_SCRIPTS_DIR string = "scripts"
BEAST_REMOTES_DIR string = "remote"
BEAST_STAGING_AREA_MOUNT_POINT string = "/beast"
BEAST_UPLOADS_DIR string = "uploads"
@@ -81,10 +78,10 @@ const ( // chall actions
const ( // chall env
MAX_PORT_PER_CHALL uint32 = 3
- BEAST_CHALLENGES_STATIC_PORT uint32 = 80
- DEFAULT_BASE_IMAGE string = "ubuntu:24.03"
+ BEAST_CHALLENGES_STATIC_PORT uint32 = 8034
+ BEAST_STATIC_CONTAINER_PORT uint32 = 8080
+ DEFAULT_BASE_IMAGE string = "ubuntu:24.04"
DEFAULT_XINETD_CONF_FILE string = "xinetd.conf"
- BEAST_STATIC_AUTH_FILE string = ".static.beast.htpasswd"
ALLOWED_MIN_PORT_VALUE uint32 = 10000
ALLOWED_MAX_PORT_VALUE uint32 = 20000
)
diff --git a/core/database/authorization_test.go b/core/database/authorization_test.go
new file mode 100644
index 00000000..727598e5
--- /dev/null
+++ b/core/database/authorization_test.go
@@ -0,0 +1,95 @@
+package database
+
+import (
+ "errors"
+ "testing"
+
+ "gorm.io/gorm"
+)
+
+func TestSetChallengeRelationsRequiresPersistedModels(t *testing.T) {
+ if err := SetChallengeRelations(nil, nil, nil); err == nil {
+ t.Fatal("expected missing challenge error")
+ }
+ if err := SetChallengeRelations(&Challenge{}, nil, nil); err == nil {
+ t.Fatal("expected unpersisted challenge error")
+ }
+ if err := SetChallengeRelations(&Challenge{Model: gorm.Model{ID: 1}}, nil, []*User{{}}); err == nil {
+ t.Fatal("expected unpersisted manager error")
+ }
+}
+
+func TestIsChallengeMaintainer(t *testing.T) {
+ cleanup := setupSubmissionTestDB(t)
+ defer cleanup()
+
+ user := createSubmissionTestUser(t, "author")
+ challenge := createSubmissionTestChallenge(t, "challenge", 0, false)
+ if err := Db.Create(&ChallengeMaintainer{UserID: user.ID, ChallengeID: challenge.ID}).Error; err != nil {
+ t.Fatal(err)
+ }
+
+ allowed, err := IsChallengeMaintainer(user.ID, challenge.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !allowed {
+ t.Fatal("expected related user to be a maintainer")
+ }
+ allowed, err = IsChallengeMaintainer(user.ID+1, challenge.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if allowed {
+ t.Fatal("unrelated user was treated as maintainer")
+ }
+}
+
+func TestUserQueriesAndCreationFailClosed(t *testing.T) {
+ cleanup := setupSubmissionTestDB(t)
+ defer cleanup()
+
+ if _, err := QueryUserById(999999); !errors.Is(err, gorm.ErrRecordNotFound) {
+ t.Fatalf("expected missing user error, got %v", err)
+ }
+ if err := CreateUserEntry(nil); err == nil {
+ t.Fatal("expected nil user error")
+ }
+
+ user := createSubmissionTestUser(t, "unique-user")
+ duplicate := User{Name: "duplicate", Email: user.Email, AuthModel: user.AuthModel}
+ if err := CreateUserEntry(&duplicate); err == nil {
+ t.Fatal("expected duplicate user error")
+ }
+}
+
+func TestMigrateChallengeMaintainersPreservesManagers(t *testing.T) {
+ cleanup := setupSubmissionTestDB(t)
+ defer cleanup()
+
+ author := createSubmissionTestUser(t, "challenge-author")
+ if err := Db.Model(&author).Update("role", "author").Error; err != nil {
+ t.Fatal(err)
+ }
+ maintainer := createSubmissionTestUser(t, "challenge-maintainer")
+ if err := Db.Model(&maintainer).Update("role", "maintainer").Error; err != nil {
+ t.Fatal(err)
+ }
+ challenge := createSubmissionTestChallenge(t, "managed-challenge", 0, false)
+ if err := Db.Model(&challenge).Update("author_id", author.ID).Error; err != nil {
+ t.Fatal(err)
+ }
+ if err := Db.Create(&UserChallenges{UserID: maintainer.ID, ChallengeID: challenge.ID}).Error; err != nil {
+ t.Fatal(err)
+ }
+
+ if err := MigrateChallengeMaintainers(); err != nil {
+ t.Fatal(err)
+ }
+ for _, userID := range []uint{author.ID, maintainer.ID} {
+ allowed, err := IsChallengeMaintainer(userID, challenge.ID)
+ if err != nil || !allowed {
+ t.Fatalf("manager %d was not migrated: allowed=%t err=%v", userID, allowed, err)
+ }
+ }
+}
diff --git a/core/database/challenge_identifiers.go b/core/database/challenge_identifiers.go
new file mode 100644
index 00000000..cf89e311
--- /dev/null
+++ b/core/database/challenge_identifiers.go
@@ -0,0 +1,34 @@
+package database
+
+import (
+ "errors"
+ "fmt"
+
+ "gorm.io/gorm"
+)
+
+func MigrateChallengeIdentifiers() error {
+ if Db == nil {
+ return errors.New("database is not initialized")
+ }
+ return Db.Transaction(func(tx *gorm.DB) error {
+ statements := []string{
+ `ALTER TABLE challenges DROP CONSTRAINT IF EXISTS uni_challenges_container_id`,
+ `ALTER TABLE challenges DROP CONSTRAINT IF EXISTS challenges_container_id_key`,
+ `ALTER TABLE challenges DROP CONSTRAINT IF EXISTS uni_challenges_image_id`,
+ `ALTER TABLE challenges DROP CONSTRAINT IF EXISTS challenges_image_id_key`,
+ `DROP INDEX IF EXISTS idx_challenges_container_id`,
+ `DROP INDEX IF EXISTS uix_challenges_container_id`,
+ `DROP INDEX IF EXISTS idx_challenges_image_id`,
+ `DROP INDEX IF EXISTS uix_challenges_image_id`,
+ `CREATE UNIQUE INDEX IF NOT EXISTS idx_challenges_active_container_id ON challenges (container_id) WHERE container_id <> '' AND deleted_at IS NULL`,
+ `CREATE UNIQUE INDEX IF NOT EXISTS idx_challenges_active_image_id ON challenges (image_id) WHERE image_id <> '' AND deleted_at IS NULL`,
+ }
+ for _, statement := range statements {
+ if err := tx.Exec(statement).Error; err != nil {
+ return fmt.Errorf("migrate challenge identifiers: %w", err)
+ }
+ }
+ return nil
+ })
+}
diff --git a/core/database/challenge_update.go b/core/database/challenge_update.go
new file mode 100644
index 00000000..220abe81
--- /dev/null
+++ b/core/database/challenge_update.go
@@ -0,0 +1,52 @@
+package database
+
+import (
+ "fmt"
+
+ "gorm.io/gorm"
+ "gorm.io/gorm/clause"
+)
+
+func UpdateChallengeConfiguration(challengeID uint, updates map[string]interface{}, ports *[]uint32, tags *[]string) error {
+ if challengeID == 0 {
+ return fmt.Errorf("persisted challenge is required")
+ }
+
+ DBMux.Lock()
+ defer DBMux.Unlock()
+ return Db.Transaction(func(tx *gorm.DB) error {
+ var challenge Challenge
+ if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&challenge, challengeID).Error; err != nil {
+ return err
+ }
+ if len(updates) > 0 {
+ if err := tx.Model(&challenge).Omit(clause.Associations).Updates(updates).Error; err != nil {
+ return err
+ }
+ }
+ if ports != nil {
+ if err := tx.Unscoped().Where("challenge_id = ?", challengeID).Delete(&Port{}).Error; err != nil {
+ return err
+ }
+ for _, portNumber := range *ports {
+ if err := tx.Create(&Port{ChallengeID: challengeID, Server: challenge.ServerDeployed, PortNo: portNumber}).Error; err != nil {
+ return fmt.Errorf("reserve port %d: %w", portNumber, err)
+ }
+ }
+ }
+ if tags != nil {
+ tagModels := make([]*Tag, 0, len(*tags))
+ for _, tagName := range *tags {
+ tag := &Tag{TagName: tagName}
+ if err := tx.FirstOrCreate(tag, Tag{TagName: tagName}).Error; err != nil {
+ return err
+ }
+ tagModels = append(tagModels, tag)
+ }
+ if err := tx.Model(&challenge).Association("Tags").Replace(tagModels); err != nil {
+ return err
+ }
+ }
+ return nil
+ })
+}
diff --git a/core/database/challenges.go b/core/database/challenges.go
index 8c6bd547..56cf75de 100644
--- a/core/database/challenges.go
+++ b/core/database/challenges.go
@@ -1,22 +1,14 @@
package database
import (
- "bytes"
- "crypto/sha256"
"errors"
"fmt"
- "html/template"
- "io/ioutil"
- "path/filepath"
"sort"
"strings"
"time"
- "github.com/araddon/dateparse"
-
"github.com/sdslabs/beastv4/core"
"github.com/sdslabs/beastv4/core/config"
- tools "github.com/sdslabs/beastv4/templates"
log "github.com/sirupsen/logrus"
"gorm.io/gorm"
@@ -58,8 +50,8 @@ type Challenge struct {
AdditionalLinks string `gorm:"type:text"`
Description string `gorm:"type:text"`
Format string `gorm:"not null"`
- ContainerId string `gorm:"size:64;unique"`
- ImageId string `gorm:"size:64;unique"`
+ ContainerId string `gorm:"size:64"`
+ ImageId string `gorm:"size:64"`
Status string `gorm:"not null;default:'Undeployed'"`
DeploymentType string `gorm:"not null;default:'standard_docker'"`
AuthorID uint `gorm:"not null"`
@@ -69,7 +61,7 @@ type Challenge struct {
MinPoints uint `gorm:"default:0"`
Ports []Port
Tags []*Tag `gorm:"many2many:tag_challenges;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"`
- Users []*User `gorm:"many2many:user_challenges;"`
+ Users []*User `gorm:"many2many:challenge_maintainers;"`
ServerDeployed string `gorm:"type:varchar(64)"`
Instanced bool `gorm:"not null;default:false"`
InstanceExpiration int64 `gorm:"default:0"`
@@ -89,6 +81,11 @@ type UserChallenges struct {
Cheating bool `gorm:"not null;default:false"`
}
+type ChallengeMaintainer struct {
+ UserID uint `gorm:"primaryKey"`
+ ChallengeID uint `gorm:"primaryKey"`
+}
+
type ChallengeAttempt struct {
Id uint `json:"id"`
UserId uint `json:"userId"`
@@ -161,8 +158,8 @@ func CreateChallengeEntry(challenge *Challenge) error {
func QueryAllChallenges() ([]Challenge, error) {
var challenges []Challenge
- DBMux.Lock()
- defer DBMux.Unlock()
+ DBMux.RLock()
+ defer DBMux.RUnlock()
tx := Db.Preload("Ports").Preload("Tags").Find(&challenges)
@@ -176,8 +173,8 @@ func QueryAllChallenges() ([]Challenge, error) {
func QueryAllChallengesMetadata() ([]Challenge, error) {
var challenges []Challenge
- DBMux.Lock()
- defer DBMux.Unlock()
+ DBMux.RLock()
+ defer DBMux.RUnlock()
tx := Db.Select("id", "name", "created_at", "points", "difficulty", "instanced", "instance_expiration", "status").
Preload("Tags").
@@ -193,12 +190,16 @@ func QueryAllChallengesMetadata() ([]Challenge, error) {
// Queries all the challenges entries where the column represented by key
// have the value in value.
func QueryChallengeEntries(key string, value string) ([]Challenge, error) {
- queryKey := fmt.Sprintf("%s = ?", key)
+ column, err := validatedQueryColumn(key, "id", "name", "status", "container_id")
+ if err != nil {
+ return nil, err
+ }
+ queryKey := fmt.Sprintf("%s = ?", column)
var challenges []Challenge
- DBMux.Lock()
- defer DBMux.Unlock()
+ DBMux.RLock()
+ defer DBMux.RUnlock()
tx := Db.Preload("Tags").Preload("Ports").Where(queryKey, value).Find(&challenges)
if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
@@ -214,12 +215,16 @@ func QueryChallengeEntries(key string, value string) ([]Challenge, error) {
// QueryChallengeEntriesMetadata returns only selected columns: Name, ID, Tags, CreatedAt, Points, Difficulty, Instanced, InstanceExpiration, Status
func QueryChallengeEntriesMetadata(key string, value string) ([]Challenge, error) {
- queryKey := fmt.Sprintf("%s = ?", key)
+ column, err := validatedQueryColumn(key, "id", "name", "status", "container_id")
+ if err != nil {
+ return nil, err
+ }
+ queryKey := fmt.Sprintf("%s = ?", column)
var challenges []Challenge
- DBMux.Lock()
- defer DBMux.Unlock()
+ DBMux.RLock()
+ defer DBMux.RUnlock()
// Only select the required columns, but preload Tags for tag names
tx := Db.Select("id", "name", "created_at", "points", "difficulty", "instanced", "instance_expiration", "status").
@@ -243,8 +248,8 @@ func QueryChallengeEntriesMap(whereMap map[string]interface{}) ([]Challenge, err
var challenges []Challenge
- DBMux.Lock()
- defer DBMux.Unlock()
+ DBMux.RLock()
+ defer DBMux.RUnlock()
tx := Db.Where(whereMap).Find(&challenges)
if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
@@ -261,16 +266,27 @@ func QueryChallengeEntriesMap(whereMap map[string]interface{}) ([]Challenge, err
// Using the column value in key and value in value get the first
// result of the query.
func QueryFirstChallengeEntry(key string, value string) (Challenge, error) {
- challenges, err := QueryChallengeEntries(key, value)
+ challenge, found, err := FindFirstChallengeEntry(key, value)
if err != nil {
return Challenge{}, err
}
+ if !found {
+ return Challenge{}, gorm.ErrRecordNotFound
+ }
+ return challenge, nil
+}
+
+func FindFirstChallengeEntry(key string, value string) (Challenge, bool, error) {
+ challenges, err := QueryChallengeEntries(key, value)
+ if err != nil {
+ return Challenge{}, false, err
+ }
if len(challenges) == 0 {
- return Challenge{}, nil
+ return Challenge{}, false, nil
}
- return challenges[0], nil
+ return challenges[0], true, nil
}
// Check Pre Reqs Status
@@ -383,10 +399,10 @@ func BatchUpdateChallenge(whereMap map[string]interface{}, chall Challenge) erro
func GetRelatedTags(challenge *Challenge) ([]Tag, error) {
var tags []Tag
- DBMux.Lock()
- defer DBMux.Unlock()
+ DBMux.RLock()
+ defer DBMux.RUnlock()
- if err := Db.Model(challenge).Association("Tags").Error; err != nil {
+ if err := Db.Model(challenge).Association("Tags").Find(&tags); err != nil {
return tags, err
}
@@ -397,8 +413,8 @@ func GetRelatedTags(challenge *Challenge) ([]Tag, error) {
func GetRelatedUsers(challenge *Challenge) ([]User, error) {
var users []User
- DBMux.Lock()
- defer DBMux.Unlock()
+ DBMux.RLock()
+ defer DBMux.RUnlock()
// Query users who have solved this challenge by checking the user_challenges table
if err := Db.Joins("JOIN user_challenges ON users.id = user_challenges.user_id").
@@ -419,8 +435,8 @@ func GetChallengeSolveInfo(challengeID uint, userID uint) (uint16, bool, error)
}
var res result
- DBMux.Lock()
- defer DBMux.Unlock()
+ DBMux.RLock()
+ defer DBMux.RUnlock()
err := Db.Raw(`
SELECT
@@ -465,8 +481,8 @@ func DeleteChallengeEntry(challenge *Challenge) error {
func QueryAllSubmissions() ([]UserChallenges, error) {
var userChallenges []UserChallenges
- DBMux.Lock()
- defer DBMux.Unlock()
+ DBMux.RLock()
+ defer DBMux.RUnlock()
tx := Db.Find(&userChallenges)
@@ -480,8 +496,8 @@ func QueryAllSubmissions() ([]UserChallenges, error) {
func QuerySubmissionsWithPagination(limit, offset int) ([]UserChallenges, error) {
var userChallenges []UserChallenges
- DBMux.Lock()
- defer DBMux.Unlock()
+ DBMux.RLock()
+ defer DBMux.RUnlock()
tx := Db.Table("user_challenges").
Select("user_challenges.*").
@@ -503,8 +519,8 @@ func QuerySubmissionsWithPagination(limit, offset int) ([]UserChallenges, error)
func QuerySubmissions(whereMap map[string]interface{}) ([]UserChallenges, error) {
var userChallenges []UserChallenges
- DBMux.Lock()
- defer DBMux.Unlock()
+ DBMux.RLock()
+ defer DBMux.RUnlock()
tx := Db.Where(whereMap).Find(&userChallenges)
if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
@@ -554,88 +570,6 @@ func SaveFlagSubmission(user_challenges *UserChallenges) error {
return tx.Commit().Error
}
-// hook after update of challenge
-func (challenge *Challenge) AfterUpdate(tx *gorm.DB) error {
- iFace, _ := tx.InstanceGet("gorm:update_attrs")
- if iFace == nil {
- return nil
- }
- updatedAttr := iFace.(map[string]interface{})
- if _, ok := updatedAttr["container_id"]; ok {
- var users []*User
- Db.Model(challenge).Association("Users")
- go updateScripts(users)
- }
- return nil
-}
-
-// hook after create of challenge
-func (challenge *Challenge) AfterCreate(tx *gorm.DB) error {
- var users []*User
- Db.Model(challenge).Association("Users")
- go updateScripts(users)
-
- return nil
-}
-
-// hook after deleting the challenge
-func (challenge *Challenge) AfterDelete(tx *gorm.DB) error {
- var users []*User
- Db.Model(challenge).Association("Users")
- go updateScripts(users)
- return nil
-}
-
-type ScriptFile struct {
- User string
- Challenges map[string]string
-}
-
-// updates users' script
-func updateScripts(users []*User) {
- for _, user := range users {
- go updateScript(user)
- }
-}
-
-// updates user script
-func updateScript(user *User) error {
-
- time.Sleep(3 * time.Second)
-
- SHA256 := sha256.New()
- SHA256.Write([]byte(user.Email))
- scriptPath := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_SCRIPTS_DIR, fmt.Sprintf("%x", SHA256.Sum(nil)))
- challs, err := GetRelatedChallenges(user)
- if err != nil {
- return fmt.Errorf("error while getting related challenges : %v", err)
- }
-
- mapOfChall := make(map[string]string)
-
- for _, chall := range challs {
- mapOfChall[chall.Name] = chall.ContainerId
- }
-
- data := ScriptFile{
- User: user.Name,
- Challenges: mapOfChall,
- }
-
- var script bytes.Buffer
- scriptTemplate, err := template.New("script").Parse(tools.SSH_LOGIN_SCRIPT_TEMPLATE)
- if err != nil {
- return fmt.Errorf("error while parsing script template :: %s", err)
- }
-
- err = scriptTemplate.Execute(&script, data)
- if err != nil {
- return fmt.Errorf("error while executing script template :: %s", err)
- }
-
- return ioutil.WriteFile(scriptPath, script.Bytes(), 0755)
-}
-
// Create a new entry in the DynamicFlag table
func CreateDynamicFlagEntry(dynamicFlag *DynamicFlag) error {
@@ -660,8 +594,8 @@ func CreateDynamicFlagEntry(dynamicFlag *DynamicFlag) error {
func QueryDynamicFlagEntries(whereMap map[string]interface{}) ([]DynamicFlag, error) {
var dynamicFlags []DynamicFlag
- DBMux.Lock()
- defer DBMux.Unlock()
+ DBMux.RLock()
+ defer DBMux.RUnlock()
whereMap = normalizeDynamicFlagWhereMap(whereMap)
tx := Db.Where(whereMap).Find(&dynamicFlags)
@@ -749,8 +683,8 @@ func DeleteAllUserChallenges(challengeID uint) error {
func QueryChallAttempts(chall_id uint64) ([]ChallengeAttempt, error) {
var attempts []ChallengeAttempt
- DBMux.Lock()
- defer DBMux.Unlock()
+ DBMux.RLock()
+ defer DBMux.RUnlock()
err := Db.Table("user_challenges").
Select("user_challenges.id as id, user_challenges.user_id as user_id, users.username as username, user_challenges.created_at as solved_at, user_challenges.flag as flag, user_challenges.solved as correct, user_challenges.cheating as cheating").
@@ -770,8 +704,8 @@ func QueryChallAttempts(chall_id uint64) ([]ChallengeAttempt, error) {
func QueryUserAttempts(user_id uint) ([]ChallengeAttempt, error) {
var attempts []ChallengeAttempt
- DBMux.Lock()
- defer DBMux.Unlock()
+ DBMux.RLock()
+ defer DBMux.RUnlock()
err := Db.Table("user_challenges").
Select("user_challenges.id as id, user_challenges.user_id as user_id, user_challenges.challenge_id as challenge_id, user_challenges.created_at as solved_at, user_challenges.flag as flag, user_challenges.solved as correct, user_challenges.cheating as cheating").
@@ -788,24 +722,16 @@ func QueryUserAttempts(user_id uint) ([]ChallengeAttempt, error) {
// timeElapsed returns the aggregation bucket for the given duration
func timeElapsed() TimeAggBucket {
- startStr := config.Cfg.CompetitionInfo.StartingTime
- // The format is "16:31:23 UTC: +05:30, 03 February 2025, Monday"
- // We'll parse only the part up to the date, ignoring the weekday
- // e.g. "16:31:23 UTC: +05:30, 03 February 2025"
- parts := strings.Split(startStr, ",")
- if len(parts) < 2 {
- log.Errorf("invalid StartingTime format: %s", startStr)
+ if config.Cfg == nil {
+ log.Error("cannot aggregate scores before config initialization")
return Between1MonthAnd1Year
}
- startTimePart := strings.TrimSpace(parts[0])
- startDatePart := strings.TrimSpace(parts[1])
- startParseStr := startTimePart + ", " + startDatePart
- start, err := dateparse.ParseLocal(startParseStr)
+ start, _, err := config.Cfg.CompetitionInfo.ParseWindow()
if err != nil {
- log.Errorf("failed to parse StartingTime: %v", err)
+ log.Errorf("parse competition window for score aggregation: %v", err)
return Between1MonthAnd1Year
}
- end := time.Now()
+ end := time.Now().In(start.Location())
diff := end.Sub(start)
minutes := diff.Minutes()
hours := diff.Hours()
@@ -900,8 +826,8 @@ func QueryTimeSeriesForTopUsers(topUserId []uint) []UserLeaderboardResp {
return results
}
- DBMux.Lock()
- defer DBMux.Unlock()
+ DBMux.RLock()
+ defer DBMux.RUnlock()
type userChallengeRow struct {
UserID uint
diff --git a/core/database/database.go b/core/database/database.go
index b632f110..897f365c 100644
--- a/core/database/database.go
+++ b/core/database/database.go
@@ -3,14 +3,16 @@ package database
import (
"crypto/rand"
"fmt"
+ "net"
+ "net/url"
"os"
- "os/exec"
"path/filepath"
"sync"
"time"
- "github.com/BurntSushi/toml"
+ "github.com/lib/pq"
"github.com/sdslabs/beastv4/core"
+ beastConfig "github.com/sdslabs/beastv4/core/config"
"github.com/sdslabs/beastv4/pkg/auth"
"github.com/sdslabs/beastv4/utils"
log "github.com/sirupsen/logrus"
@@ -19,46 +21,59 @@ import (
)
var (
- DBMux *sync.Mutex
+ DBMux *sync.RWMutex
Db *gorm.DB
- dberr error
)
-var (
- BEAST_GLOBAL_DIR string = filepath.Join(os.Getenv("HOME"), ".beast")
- dbConfig Config
-)
+var dbConfig beastConfig.PsqlConfig
-type Config struct {
- PsqlConf PSQLConfig `toml:"psql_config"`
+func LoadDbConfig() error {
+ if beastConfig.Cfg != nil {
+ dbConfig = beastConfig.Cfg.PsqlConf
+ return nil
+ }
+ cfg, err := beastConfig.LoadBeastConfig(filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_CONFIG_FILE_NAME))
+ if err != nil {
+ return fmt.Errorf("load database config: %w", err)
+ }
+ dbConfig = cfg.PsqlConf
+ return nil
}
-type PSQLConfig struct {
- User string `toml:"user"`
- Password string `toml:"password"`
- Dbname string `toml:"dbname"`
- Host string `toml:"host"`
- Port string `toml:"port"`
- SslMode string `toml:"sslmode"`
+
+func postgresDSN(config beastConfig.PsqlConfig) string {
+ dsn := &url.URL{
+ Scheme: "postgresql",
+ User: url.UserPassword(config.User, config.Password),
+ Host: net.JoinHostPort(config.Host, config.Port),
+ Path: config.Dbname,
+ }
+ query := dsn.Query()
+ query.Set("sslmode", config.SslMode)
+ if config.SSLRootCert != "" {
+ query.Set("sslrootcert", config.SSLRootCert)
+ }
+ dsn.RawQuery = query.Encode()
+ return dsn.String()
}
-// Db config is loaded separately here for temp use because init() function is
-// called during initialization of package.
-// It is also loaded during db backup/reset
-func LoadDbConfig() {
- if _, err := toml.DecodeFile(filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_CONFIG_FILE_NAME), &dbConfig); err != nil {
- log.Fatalf("Error loading TOML file: %v", err)
+func postgresEnvironment(config beastConfig.PsqlConfig) []string {
+ environment := append(os.Environ(), "PGPASSWORD="+config.Password, "PGSSLMODE="+config.SslMode)
+ if config.SSLRootCert != "" {
+ environment = append(environment, "PGSSLROOTCERT="+config.SSLRootCert)
}
+ return environment
}
// Connect psql database
func ConnectDatabase() error {
- LoadDbConfig()
- dsn := fmt.Sprintf("user=%s password=%s dbname=%s host=%s port=%s sslmode=%s", dbConfig.PsqlConf.User, dbConfig.PsqlConf.Password, dbConfig.PsqlConf.Dbname, dbConfig.PsqlConf.Host, dbConfig.PsqlConf.Port, dbConfig.PsqlConf.SslMode)
- Db, dberr = gorm.Open(postgres.Open(dsn), &gorm.Config{})
- if dberr != nil {
- log.Error("Error while initializing the database.", dberr)
- return dberr
+ if err := LoadDbConfig(); err != nil {
+ return err
+ }
+ db, err := gorm.Open(postgres.Open(postgresDSN(dbConfig)), &gorm.Config{})
+ if err != nil {
+ return err
}
+ Db = db
log.Debug("Database initialized")
return nil
}
@@ -67,111 +82,98 @@ func ConnectDatabase() error {
// Postgresql database for beast. The Db variable is the connection variable for the
// database, which is not closed after creating a connection here and can
// be used further after this.
-func Init() {
- DBMux = &sync.Mutex{}
+func Init() error {
+ DBMux = &sync.RWMutex{}
if Db == nil {
- dberr = ConnectDatabase()
- if dberr != nil {
- log.Error("Error while initializing the database.", dberr)
+ if err := ConnectDatabase(); err != nil {
+ return fmt.Errorf("initialize database: %w", err)
}
}
if err := Db.SetupJoinTable(&Challenge{}, "Users", &UserChallenges{}); err != nil {
- log.Fatalf("Cannot create related models: %s", err)
+ return fmt.Errorf("configure user challenge relation: %w", err)
+ }
+ if err := Db.SetupJoinTable(&User{}, "Challenges", &ChallengeMaintainer{}); err != nil {
+ return fmt.Errorf("configure user maintainer relation: %w", err)
}
- if err := Db.SetupJoinTable(&User{}, "Challenges", &UserChallenges{}); err != nil {
- log.Fatalf("Cannot create related models: %s", err)
+ if err := Db.SetupJoinTable(&Challenge{}, "Users", &ChallengeMaintainer{}); err != nil {
+ return fmt.Errorf("configure challenge maintainer relation: %w", err)
}
if err := Db.SetupJoinTable(&User{}, "Hints", &UserHint{}); err != nil {
- log.Fatalf("Cannot create related models: %s", err)
+ return fmt.Errorf("configure user hint relation: %w", err)
}
// UserHint must be explicitly migrated since GORM's AutoMigrate on User only handles
// the users table, not custom join table structs. Without this, the created_at and
// challenge_id columns on user_hints won't be added to existing databases.
- err := Db.AutoMigrate(&Challenge{}, &Transaction{}, &Port{}, &User{}, &UserChallenges{}, &Tag{}, &Notification{}, &Hint{}, &DynamicFlag{}, &DynamicFlagClaim{}, &DynamicScoreDirty{}, &OTP{}, &UserHint{})
+ err := Db.AutoMigrate(&Challenge{}, &Transaction{}, &Port{}, &User{}, &UserChallenges{}, &ChallengeMaintainer{}, &Tag{}, &Notification{}, &Hint{}, &DynamicFlag{}, &DynamicFlagClaim{}, &DynamicScoreDirty{}, &OTP{}, &UserHint{})
if err != nil {
- log.Fatalf("failed to migrate database with error: %s", err)
+ return fmt.Errorf("migrate database: %w", err)
}
if err := MigrateSubmissionGuards(); err != nil {
- log.Fatalf("failed to migrate submission guards with error: %s", err)
+ return fmt.Errorf("migrate submission guards: %w", err)
+ }
+ if err := MigratePortUniqueness(); err != nil {
+ return fmt.Errorf("migrate port uniqueness: %w", err)
+ }
+ if err := MigrateChallengeIdentifiers(); err != nil {
+ return fmt.Errorf("migrate challenge identifiers: %w", err)
+ }
+ if err := MigrateChallengeMaintainers(); err != nil {
+ return fmt.Errorf("migrate challenge maintainers: %w", err)
+ }
+ if err := ClearLegacyOTPSecrets(); err != nil {
+ return fmt.Errorf("clear legacy OTP secrets: %w", err)
}
users, err := QueryUserEntries("email", core.DEFAULT_USER_EMAIL)
if err != nil {
- log.Fatalf("Error while checking dummy user entry.")
+ return fmt.Errorf("check dummy user entry: %w", err)
}
if len(users) == 0 {
log.Info("Creating dummy user entry")
- salt := make([]byte, 16)
- rand.Read(salt)
randPass := make([]byte, 32)
- rand.Read(randPass)
+ if _, err := rand.Read(randPass); err != nil {
+ return fmt.Errorf("generate dummy user password: %w", err)
+ }
+ authModel, err := auth.CreateModel(core.DEFAULT_USER_NAME, string(randPass), core.USER_ROLES["author"])
+ if err != nil {
+ return fmt.Errorf("hash dummy user password: %w", err)
+ }
- err := CreateUserEntry(&User{
+ err = CreateUserEntry(&User{
Name: core.DEFAULT_USER_NAME,
Email: core.DEFAULT_USER_EMAIL,
- AuthModel: auth.CreateModel(core.DEFAULT_USER_NAME, string(randPass), core.USER_ROLES["author"]),
+ AuthModel: authModel,
})
if err != nil {
- log.Errorf("Error while creating dummy user entry.")
- os.Exit(1)
+ return fmt.Errorf("create dummy user entry: %w", err)
}
}
+ return nil
}
-func BackupAndReset() {
- LoadDbConfig()
-
- err := BackupDatabase()
- if err != nil {
- log.Errorf("Error while backing up database: %s", err)
- return
- }
- err = ResetDatabase()
- if err != nil {
- log.Errorf("Error while resetting up database: %s", err)
- return
- }
-
- backupPath := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_BACKUP_DIR, core.BEAST_REMOTES_DIR)
- err = utils.CreateIfNotExistDir(backupPath)
- if err != nil {
- log.Errorf("Error while creating backup directory: %s", err)
- return
- }
-
- backupPath = filepath.Join(backupPath, core.BEAST_REMOTES_DIR+time.Now().Format("20060102150405")+".bak")
- oldPath := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_REMOTES_DIR)
- err = os.Rename(oldPath, backupPath)
- if err != nil {
- log.Errorf("Error while backing up remote dir: %s", err)
- return
+func BackupAndReset() error {
+ if err := LoadDbConfig(); err != nil {
+ return err
}
-
- backupPath = filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_BACKUP_DIR, core.BEAST_STAGING_DIR)
-
- err = utils.CreateIfNotExistDir(backupPath)
- if err != nil {
- log.Errorf("Error while creating backup directory: %s", err)
- return
+ if err := BackupDatabase(); err != nil {
+ return fmt.Errorf("back up database: %w", err)
}
-
- oldPath = filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_STAGING_DIR)
- backupPath = filepath.Join(backupPath, core.BEAST_STAGING_DIR+time.Now().Format("20060102150405")+".bak")
- err = os.Rename(oldPath, backupPath)
- if err != nil {
- log.Errorf("Error while backing up staging dir: %s", err)
- return
+ if err := ResetDatabase(); err != nil {
+ return fmt.Errorf("reset database: %w", err)
}
+ return nil
}
func BackupDatabase() error {
- if dbConfig == (Config{}) {
- LoadDbConfig()
+ if dbConfig == (beastConfig.PsqlConfig{}) {
+ if err := LoadDbConfig(); err != nil {
+ return err
+ }
}
backupPath := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_BACKUP_DIR, core.DB_BACKUP_DIR)
@@ -181,106 +183,59 @@ func BackupDatabase() error {
return err
}
- backupFile := fmt.Sprintf("%s_%s.bak", dbConfig.PsqlConf.Dbname, time.Now().Format("20060102150405"))
- cmd := exec.Command("pg_dump", "-U", dbConfig.PsqlConf.User, "-h", dbConfig.PsqlConf.Host, "-p", dbConfig.PsqlConf.Port, "-F", "c", "-f", filepath.Join(backupPath, backupFile), dbConfig.PsqlConf.Dbname)
- cmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", dbConfig.PsqlConf.Password))
- output, err := cmd.CombinedOutput()
+ backupFile := fmt.Sprintf("%s_%s.bak", dbConfig.Dbname, time.Now().Format("20060102150405"))
+ environment := postgresEnvironment(dbConfig)
+ output, err := utils.RunCommand(30*time.Minute, environment, "pg_dump", "-U", dbConfig.User, "-h", dbConfig.Host, "-p", dbConfig.Port, "-F", "c", "-f", filepath.Join(backupPath, backupFile), dbConfig.Dbname)
if err != nil {
- log.Printf("Backup error: %s\n", string(output))
- return err
+ return fmt.Errorf("pg_dump failed: %w; output: %s", err, output)
}
log.Debug("Backup successful.")
return nil
}
func ResetDatabase() error {
- if dbConfig == (Config{}) {
- LoadDbConfig()
+ if dbConfig == (beastConfig.PsqlConfig{}) {
+ if err := LoadDbConfig(); err != nil {
+ return err
+ }
}
- err := TerminateDatabaseConnections()
+ environment := postgresEnvironment(dbConfig)
+ output, err := utils.RunCommand(2*time.Minute, environment, "dropdb", "-U", dbConfig.User, "-h", dbConfig.Host, "-p", dbConfig.Port, "--force", dbConfig.Dbname)
if err != nil {
- log.Errorf("Unable to terminate connections %s", err)
- return err
+ return fmt.Errorf("drop database: %w; output: %s", err, output)
}
- dropCmd := exec.Command("dropdb", "-U", dbConfig.PsqlConf.User, "-h", dbConfig.PsqlConf.Host, "-p", dbConfig.PsqlConf.Port, "--force", dbConfig.PsqlConf.Dbname)
- dropCmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", dbConfig.PsqlConf.Password))
-
- output, err := dropCmd.CombinedOutput()
+ output, err = utils.RunCommand(2*time.Minute, environment, "psql", "-U", dbConfig.User, "-h", dbConfig.Host, "-p", dbConfig.Port, "-d", "postgres", "-c", "CREATE DATABASE "+pq.QuoteIdentifier(dbConfig.Dbname)+";")
if err != nil {
- log.Printf("Drop DB error: %s\n", string(output))
- return err
- }
-
- createCmd := exec.Command("psql", "-U", dbConfig.PsqlConf.User, "-h", dbConfig.PsqlConf.Host, "-p", dbConfig.PsqlConf.Port, "-d", "postgres", "-c", "CREATE DATABASE "+dbConfig.PsqlConf.Dbname+";")
- createCmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", dbConfig.PsqlConf.Password))
-
- output, err = createCmd.CombinedOutput()
- if err != nil {
- log.Printf("Create DB error: %s\n", string(output))
- return err
+ return fmt.Errorf("create database: %w; output: %s", err, output)
}
log.Debug("Reset successful.")
return nil
}
-// Terminate all active connections before dropping
-func TerminateDatabaseConnections() error {
- if dbConfig == (Config{}) {
- LoadDbConfig()
- }
- terminateCmd := exec.Command(
- "psql",
- "-U", dbConfig.PsqlConf.User,
- "-h", dbConfig.PsqlConf.Host,
- "-p", dbConfig.PsqlConf.Port,
- "-d", "postgres",
- "-c",
- fmt.Sprintf("SELECT pg_terminate_backend(pg_stat_activity.pid) FROM pg_stat_activity WHERE datname = '%s' AND pid <> pg_backend_pid();", dbConfig.PsqlConf.Dbname),
- )
- terminateCmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", dbConfig.PsqlConf.Password))
-
- output, err := terminateCmd.CombinedOutput()
- outputStr := string(output)
- if err != nil {
- log.Errorf("Terminate connections error: %s\n", outputStr)
- return err
- }
- log.Debug(outputStr)
- return nil
-}
-
func RestoreDatabase(backupFile string) error {
- LoadDbConfig()
-
- err := TerminateDatabaseConnections()
- if err != nil {
- log.Errorf("Unable to terminate connections: %s ", err)
+ if err := LoadDbConfig(); err != nil {
return err
}
- err = utils.ValidateFileExists(backupFile)
+ err := utils.ValidateFileExists(backupFile)
if err != nil {
return fmt.Errorf("backup file does not exist: %s", backupFile)
}
- restoreCmd := exec.Command(
- "pg_restore",
- "-U", dbConfig.PsqlConf.User,
- "-h", dbConfig.PsqlConf.Host,
- "-p", dbConfig.PsqlConf.Port,
- "-d", dbConfig.PsqlConf.Dbname,
+ environment := postgresEnvironment(dbConfig)
+ output, err := utils.RunCommand(30*time.Minute, environment, "pg_restore",
+ "-U", dbConfig.User,
+ "-h", dbConfig.Host,
+ "-p", dbConfig.Port,
+ "-d", dbConfig.Dbname,
"--no-owner",
"--clean",
"--if-exists",
backupFile,
)
- restoreCmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", dbConfig.PsqlConf.Password))
-
- output, err := restoreCmd.CombinedOutput()
if err != nil {
- log.Printf("Restore DB error: %s\n", string(output))
- return fmt.Errorf("failed to restore database from %s: %v", backupFile, err)
+ return fmt.Errorf("restore database from %s: %w; output: %s", backupFile, err, output)
}
log.Println("Database restored successfully from:", backupFile)
diff --git a/core/database/database_test.go b/core/database/database_test.go
new file mode 100644
index 00000000..8ee9cb1b
--- /dev/null
+++ b/core/database/database_test.go
@@ -0,0 +1,40 @@
+package database
+
+import (
+ "net/url"
+ "testing"
+
+ "github.com/sdslabs/beastv4/core/config"
+)
+
+func TestPostgresDSNEncodesCredentials(t *testing.T) {
+ dsn := postgresDSN(config.PsqlConfig{
+ User: "beast user",
+ Password: "secret with =' delimiters",
+ Dbname: "beast-db",
+ Host: "127.0.0.1",
+ Port: "5432",
+ SslMode: "require",
+ SSLRootCert: "/etc/ssl/certs/root.pem",
+ })
+ parsed, err := url.Parse(dsn)
+ if err != nil {
+ t.Fatal(err)
+ }
+ password, _ := parsed.User.Password()
+ if parsed.User.Username() != "beast user" || password != "secret with =' delimiters" {
+ t.Fatalf("credentials did not round trip through DSN: %s", dsn)
+ }
+ if parsed.Query().Get("sslmode") != "require" {
+ t.Fatalf("sslmode did not round trip through DSN: %s", dsn)
+ }
+ if parsed.Query().Get("sslrootcert") != "/etc/ssl/certs/root.pem" {
+ t.Fatalf("sslrootcert did not round trip through DSN: %s", dsn)
+ }
+}
+
+func TestChallengeMetadataQueryRejectsUnknownColumn(t *testing.T) {
+ if _, err := QueryChallengeEntriesMetadata("name OR true", "challenge"); err == nil {
+ t.Fatal("expected unsafe metadata query column error")
+ }
+}
diff --git a/core/database/hints.go b/core/database/hints.go
index d45e087f..9ba5004d 100644
--- a/core/database/hints.go
+++ b/core/database/hints.go
@@ -46,8 +46,8 @@ func CreateHintEntry(hint *Hint) error {
}
func GetHintByID(hintID uint) (*Hint, error) {
- DBMux.Lock()
- defer DBMux.Unlock()
+ DBMux.RLock()
+ defer DBMux.RUnlock()
tx := Db.Begin()
if tx.Error != nil {
@@ -73,8 +73,8 @@ func GetHintByID(hintID uint) (*Hint, error) {
// checks if user has already taken the hint
func UserHasTakenHint(userID, hintID uint) (bool, error) {
- DBMux.Lock()
- defer DBMux.Unlock()
+ DBMux.RLock()
+ defer DBMux.RUnlock()
tx := Db.Begin()
if tx.Error != nil {
@@ -156,8 +156,8 @@ func QueryHintsTaken(userID, challengeID uint) ([]Hint, error) {
var userHints []UserHint
var hints []Hint
- DBMux.Lock()
- defer DBMux.Unlock()
+ DBMux.RLock()
+ defer DBMux.RUnlock()
tx := Db.Where("user_id = ? AND challenge_id = ?", userID, challengeID).Find(&userHints)
if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
@@ -188,8 +188,8 @@ func QueryHintsTaken(userID, challengeID uint) ([]Hint, error) {
func QueryHintsByChallengeID(challengeID uint) ([]Hint, error) {
var hints []Hint
- DBMux.Lock()
- defer DBMux.Unlock()
+ DBMux.RLock()
+ defer DBMux.RUnlock()
if err := Db.Where("challenge_id = ?", challengeID).Find(&hints).Error; err != nil {
return nil, err
diff --git a/core/database/maintainers.go b/core/database/maintainers.go
new file mode 100644
index 00000000..4198ab24
--- /dev/null
+++ b/core/database/maintainers.go
@@ -0,0 +1,48 @@
+package database
+
+import "fmt"
+
+func MigrateChallengeMaintainers() error {
+ if Db == nil {
+ return fmt.Errorf("database is not initialized")
+ }
+ return Db.Exec(`
+INSERT INTO challenge_maintainers (user_id, challenge_id)
+SELECT users.id, challenges.id
+FROM users
+JOIN challenges ON challenges.author_id = users.id
+UNION
+SELECT uc.user_id, uc.challenge_id
+FROM user_challenges uc
+JOIN users ON users.id = uc.user_id
+WHERE users.role IN ('author', 'maintainer', 'admin')
+ON CONFLICT (user_id, challenge_id) DO NOTHING
+`).Error
+}
+
+func SetChallengeRelations(challenge *Challenge, tags []*Tag, users []*User) error {
+ if challenge == nil || challenge.ID == 0 {
+ return fmt.Errorf("persisted challenge is required")
+ }
+ for _, user := range users {
+ if user == nil || user.ID == 0 {
+ return fmt.Errorf("persisted challenge manager is required")
+ }
+ }
+
+ DBMux.Lock()
+ defer DBMux.Unlock()
+ tx := Db.Begin()
+ if tx.Error != nil {
+ return tx.Error
+ }
+ if err := tx.Model(challenge).Association("Tags").Replace(tags); err != nil {
+ tx.Rollback()
+ return fmt.Errorf("set challenge tags: %w", err)
+ }
+ if err := tx.Model(challenge).Association("Users").Replace(users); err != nil {
+ tx.Rollback()
+ return fmt.Errorf("set challenge managers: %w", err)
+ }
+ return tx.Commit().Error
+}
diff --git a/core/database/notification.go b/core/database/notification.go
index 584fc290..18aa5bb1 100644
--- a/core/database/notification.go
+++ b/core/database/notification.go
@@ -11,8 +11,8 @@ import (
type Notification struct {
gorm.Model
- Title string `gorm:not null;unique`
- Description string `gorm:not null`
+ Title string `gorm:"not null;unique"`
+ Description string `gorm:"not null"`
}
// Create an entry for the notification in the Notification table
@@ -57,12 +57,16 @@ func DeleteNotification(notification *Notification) error {
// Queries all the challenges entries where the column represented by key
// have the value in value.
func QueryNotificationEntries(key string, value string) ([]Notification, error) {
- queryKey := fmt.Sprintf("%s = ?", key)
+ column, err := validatedQueryColumn(key, "id")
+ if err != nil {
+ return nil, err
+ }
+ queryKey := fmt.Sprintf("%s = ?", column)
var notifications []Notification
- DBMux.Lock()
- defer DBMux.Unlock()
+ DBMux.RLock()
+ defer DBMux.RUnlock()
tx := Db.Where(queryKey, value).Find(¬ifications)
if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
@@ -95,8 +99,8 @@ func QueryFirstNotificationEntry(key string, value string) (Notification, error)
func QueryAllNotification() ([]Notification, error) {
var notifications []Notification
- DBMux.Lock()
- defer DBMux.Unlock()
+ DBMux.RLock()
+ defer DBMux.RUnlock()
tx := Db.Find(¬ifications)
if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
diff --git a/core/database/otp.go b/core/database/otp.go
index 0218813a..72c9a321 100644
--- a/core/database/otp.go
+++ b/core/database/otp.go
@@ -1,45 +1,136 @@
package database
import (
+ "crypto/subtle"
+ "errors"
"time"
+
+ "gorm.io/gorm"
+ "gorm.io/gorm/clause"
+)
+
+var (
+ ErrOTPRateLimited = errors.New("OTP was requested too recently")
+ ErrOTPInvalid = errors.New("invalid OTP")
+ ErrOTPExpired = errors.New("OTP expired")
+ ErrOTPAttempts = errors.New("too many OTP attempts")
+)
+
+const (
+ otpResendCooldown = time.Minute
+ maxOTPAttempts = 5
)
type OTP struct {
Email string `gorm:"primaryKey"`
- Code string
Expiry time.Time
Verified bool
+ Purpose string
+ CodeHash []byte
+ Attempts uint
+ SentAt time.Time
+}
+
+func ClearLegacyOTPSecrets() error {
+ if !Db.Migrator().HasColumn(&OTP{}, "code") {
+ return nil
+ }
+ return Db.Exec("UPDATE otps SET code = ''").Error
}
-func CreateOTPEntry(otpEntry *OTP) error {
+func IssueOTP(email, purpose string, codeHash []byte, now, expiry time.Time) error {
DBMux.Lock()
defer DBMux.Unlock()
- var existingOTP OTP
- tx := Db.First(&existingOTP, "email = ?", otpEntry.Email)
- if tx.Error == nil {
- existingOTP.Code = otpEntry.Code
- existingOTP.Expiry = otpEntry.Expiry
- return Db.Save(&existingOTP).Error
- }
+ return Db.Transaction(func(tx *gorm.DB) error {
+ var entry OTP
+ err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("email = ?", email).First(&entry).Error
+ if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
+ return err
+ }
+ if err == nil && !entry.SentAt.IsZero() && now.Before(entry.SentAt.Add(otpResendCooldown)) {
+ return ErrOTPRateLimited
+ }
- return Db.Create(otpEntry).Error
+ entry.Email = email
+ entry.Purpose = purpose
+ entry.CodeHash = append([]byte(nil), codeHash...)
+ entry.Expiry = expiry
+ entry.SentAt = now
+ entry.Attempts = 0
+ entry.Verified = false
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return tx.Create(&entry).Error
+ }
+ return tx.Save(&entry).Error
+ })
}
-func QueryOTPEntry(email string) (OTP, error) {
- var otpEntry OTP
-
+func VerifyOTPCode(email, purpose string, codeHash []byte, now time.Time) error {
DBMux.Lock()
defer DBMux.Unlock()
- tx := Db.Where("email = ?", email).First(&otpEntry)
+ var result error
+ err := Db.Transaction(func(tx *gorm.DB) error {
+ var entry OTP
+ if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("email = ?", email).First(&entry).Error; err != nil {
+ return err
+ }
+ switch {
+ case entry.Verified || entry.Purpose != purpose || len(entry.CodeHash) == 0:
+ result = ErrOTPInvalid
+ case now.After(entry.Expiry):
+ result = ErrOTPExpired
+ case entry.Attempts >= maxOTPAttempts:
+ result = ErrOTPAttempts
+ case subtle.ConstantTimeCompare(entry.CodeHash, codeHash) != 1:
+ entry.Attempts++
+ result = ErrOTPInvalid
+ return tx.Model(&entry).Update("attempts", entry.Attempts).Error
+ default:
+ result = nil
+ return tx.Model(&entry).Updates(map[string]interface{}{
+ "verified": true,
+ "code_hash": []byte(nil),
+ }).Error
+ }
+ return nil
+ })
+ if err != nil {
+ return err
+ }
+ return result
+}
- return otpEntry, tx.Error
+func DeleteOTPEntry(email string) error {
+ DBMux.Lock()
+ defer DBMux.Unlock()
+ return Db.Delete(&OTP{}, "email = ?", email).Error
}
-func VerifyOTPEntry(email string) error {
+func ConsumeVerifiedOTP(email, purpose string, now time.Time) error {
DBMux.Lock()
defer DBMux.Unlock()
- return Db.Model(&OTP{}).Where("email = ?", email).Update("verified", true).Error
+ return Db.Transaction(func(tx *gorm.DB) error {
+ var entry OTP
+ if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("email = ?", email).First(&entry).Error; err != nil {
+ return err
+ }
+ if !entry.Verified || entry.Purpose != purpose || now.After(entry.Expiry) {
+ return ErrOTPInvalid
+ }
+ return tx.Delete(&entry).Error
+ })
+}
+
+func QueryOTPEntry(email string) (OTP, error) {
+ var otpEntry OTP
+
+ DBMux.RLock()
+ defer DBMux.RUnlock()
+
+ tx := Db.Where("email = ?", email).First(&otpEntry)
+
+ return otpEntry, tx.Error
}
diff --git a/core/database/ports.go b/core/database/ports.go
index 329318f5..4c5330d8 100644
--- a/core/database/ports.go
+++ b/core/database/ports.go
@@ -1,16 +1,18 @@
package database
import (
+ "errors"
"fmt"
- "github.com/jinzhu/gorm"
+ "gorm.io/gorm"
)
type Port struct {
gorm.Model
ChallengeID uint `gorm:"not null"`
- PortNo uint32 `gorm:"not null;unique"`
+ Server string `gorm:"not null;default:'';uniqueIndex:idx_ports_server_port"`
+ PortNo uint32 `gorm:"not null;uniqueIndex:idx_ports_server_port"`
}
// Create an entry for the port in the Port table
@@ -18,29 +20,30 @@ type Port struct {
// transaction. If the entry already exists then it does not
// do anything and returns.
func PortEntryGetOrCreate(port *Port) (Port, error) {
+ if port == nil || port.ChallengeID == 0 {
+ return Port{}, errors.New("persisted challenge port is required")
+ }
DBMux.Lock()
defer DBMux.Unlock()
- tx := Db.Begin()
-
- if tx.Error != nil {
- return Port{}, fmt.Errorf("Error while starting transaction : %s", tx.Error)
- }
-
- err := tx.FirstOrCreate(port, *port).Error
- if err != nil {
- tx.Rollback()
- return Port{}, err
- }
-
- return *port, tx.Commit().Error
+ err := Db.Transaction(func(tx *gorm.DB) error {
+ if port.Server == "" {
+ var challenge Challenge
+ if err := tx.Select("server_deployed").First(&challenge, port.ChallengeID).Error; err != nil {
+ return fmt.Errorf("resolve challenge server: %w", err)
+ }
+ port.Server = challenge.ServerDeployed
+ }
+ return tx.FirstOrCreate(port, Port{Server: port.Server, PortNo: port.PortNo}).Error
+ })
+ return *port, err
}
func GetAllocatedPorts(challenge Challenge) ([]Port, error) {
var ports []Port
- DBMux.Lock()
- defer DBMux.Unlock()
+ DBMux.RLock()
+ defer DBMux.RUnlock()
if err := Db.Model(&challenge).Association("Ports").Find(&ports); err != nil {
return nil, fmt.Errorf("error while searching port for challenge : %s", err)
@@ -50,40 +53,58 @@ func GetAllocatedPorts(challenge Challenge) ([]Port, error) {
}
func UpdatePorts(challenge *Challenge) error {
- var ports []Port
+ if challenge == nil || challenge.ID == 0 {
+ return errors.New("persisted challenge is required")
+ }
DBMux.Lock()
defer DBMux.Unlock()
- tx := Db.Begin()
-
- if err := tx.Model(&challenge).Association("Ports").Find(&ports); err != nil {
- return fmt.Errorf("error while searching port for challenge : %s", err)
- }
- tx.Commit()
-
- if err := Db.Unscoped().Where("challenge_id = ?", challenge.ID).Delete(ports); err != nil {
- return err.Error
- }
-
- return nil
+ return Db.Unscoped().Where("challenge_id = ?", challenge.ID).Delete(&Port{}).Error
}
func DeleteRelatedPorts(portList []Port) error {
+ if len(portList) == 0 {
+ return nil
+ }
+ ids := make([]uint, 0, len(portList))
+ for _, port := range portList {
+ if port.ID == 0 {
+ return errors.New("persisted port is required")
+ }
+ ids = append(ids, port.ID)
+ }
DBMux.Lock()
defer DBMux.Unlock()
- tx := Db.Begin()
-
- if tx.Error != nil {
- return fmt.Errorf("Error while starting transaction : %s", tx.Error)
- }
+ return Db.Unscoped().Where("id IN ?", ids).Delete(&Port{}).Error
+}
- if err := tx.Where("1 = 1").Unscoped().Delete(portList).Error; err != nil {
- tx.Rollback()
- return err
+func MigratePortUniqueness() error {
+ if Db == nil {
+ return errors.New("database is not initialized")
}
-
- return tx.Commit().Error
+ return Db.Transaction(func(tx *gorm.DB) error {
+ if err := tx.Exec(`
+UPDATE ports
+SET server = challenges.server_deployed
+FROM challenges
+WHERE ports.challenge_id = challenges.id AND ports.server = ''`).Error; err != nil {
+ return fmt.Errorf("backfill port servers: %w", err)
+ }
+ statements := []string{
+ `ALTER TABLE ports DROP CONSTRAINT IF EXISTS uni_ports_port_no`,
+ `ALTER TABLE ports DROP CONSTRAINT IF EXISTS ports_port_no_key`,
+ `DROP INDEX IF EXISTS idx_ports_port_no`,
+ `DROP INDEX IF EXISTS uix_ports_port_no`,
+ `CREATE UNIQUE INDEX IF NOT EXISTS idx_ports_server_port ON ports (server, port_no)`,
+ }
+ for _, statement := range statements {
+ if err := tx.Exec(statement).Error; err != nil {
+ return fmt.Errorf("migrate port uniqueness: %w", err)
+ }
+ }
+ return nil
+ })
}
diff --git a/core/database/query.go b/core/database/query.go
new file mode 100644
index 00000000..bb7677ce
--- /dev/null
+++ b/core/database/query.go
@@ -0,0 +1,16 @@
+package database
+
+import (
+ "fmt"
+ "strings"
+)
+
+func validatedQueryColumn(key string, allowed ...string) (string, error) {
+ column := strings.ToLower(strings.TrimSpace(key))
+ for _, candidate := range allowed {
+ if column == candidate {
+ return column, nil
+ }
+ }
+ return "", fmt.Errorf("unsupported query column %q", key)
+}
diff --git a/core/database/query_test.go b/core/database/query_test.go
new file mode 100644
index 00000000..2eb9809b
--- /dev/null
+++ b/core/database/query_test.go
@@ -0,0 +1,12 @@
+package database
+
+import "testing"
+
+func TestValidatedQueryColumnRejectsSQLFragments(t *testing.T) {
+ if column, err := validatedQueryColumn("ID", "id", "name"); err != nil || column != "id" {
+ t.Fatalf("expected normalized ID column, got %q, %v", column, err)
+ }
+ if _, err := validatedQueryColumn("id OR 1=1", "id", "name"); err == nil {
+ t.Fatal("expected SQL fragment to be rejected")
+ }
+}
diff --git a/core/database/relations_test.go b/core/database/relations_test.go
new file mode 100644
index 00000000..0da1e2a2
--- /dev/null
+++ b/core/database/relations_test.go
@@ -0,0 +1,158 @@
+package database
+
+import (
+ "errors"
+ "testing"
+
+ "gorm.io/gorm"
+)
+
+func TestTagQueriesReturnChallengeRelations(t *testing.T) {
+ cleanup := setupSubmissionTestDB(t)
+ defer cleanup()
+
+ challenge := createSubmissionTestChallenge(t, "tagged-challenge", -1, false)
+ tags := []*Tag{{TagName: "web"}, {TagName: "linux"}}
+ if err := UpdateTags(tags, &challenge); err != nil {
+ t.Fatal(err)
+ }
+
+ related, err := GetRelatedTags(&challenge)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(related) != 2 {
+ t.Fatalf("expected two related tags, got %d", len(related))
+ }
+
+ unique, err := QueryAllUniqueTags()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(unique) != 2 || unique[0] != "linux" || unique[1] != "web" {
+ t.Fatalf("unexpected unique tags: %v", unique)
+ }
+
+ challenges, err := QueryRelatedChallenges(&Tag{TagName: "web"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(challenges) != 1 || challenges[0].ID != challenge.ID {
+ t.Fatalf("unexpected related challenges: %#v", challenges)
+ }
+ if _, err := QueryRelatedChallenges(&Tag{TagName: "missing"}); !errors.Is(err, gorm.ErrRecordNotFound) {
+ t.Fatalf("expected missing tag error, got %v", err)
+ }
+}
+
+func TestPortsAreUniquePerServerAndDeleteByID(t *testing.T) {
+ cleanup := setupSubmissionTestDB(t)
+ defer cleanup()
+
+ first := createSubmissionTestChallenge(t, "first-port", -1, false)
+ first.ServerDeployed = "worker-a"
+ if err := Db.Model(&first).Update("server_deployed", first.ServerDeployed).Error; err != nil {
+ t.Fatal(err)
+ }
+ second := createSubmissionTestChallenge(t, "second-port", -1, false)
+ second.ServerDeployed = "worker-b"
+ if err := Db.Model(&second).Update("server_deployed", second.ServerDeployed).Error; err != nil {
+ t.Fatal(err)
+ }
+
+ firstPort, err := PortEntryGetOrCreate(&Port{ChallengeID: first.ID, PortNo: 8080})
+ if err != nil {
+ t.Fatal(err)
+ }
+ secondPort, err := PortEntryGetOrCreate(&Port{ChallengeID: second.ID, PortNo: 8080})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if firstPort.Server == secondPort.Server {
+ t.Fatalf("expected distinct port servers, got %q", firstPort.Server)
+ }
+
+ third := createSubmissionTestChallenge(t, "third-port", -1, false)
+ third.ServerDeployed = first.ServerDeployed
+ if err := Db.Model(&third).Update("server_deployed", third.ServerDeployed).Error; err != nil {
+ t.Fatal(err)
+ }
+ existing, err := PortEntryGetOrCreate(&Port{ChallengeID: third.ID, PortNo: 8080})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if existing.ChallengeID != first.ID {
+ t.Fatalf("same-server port should remain owned by challenge %d, got %d", first.ID, existing.ChallengeID)
+ }
+
+ if err := DeleteRelatedPorts(nil); err != nil {
+ t.Fatal(err)
+ }
+ if err := DeleteRelatedPorts([]Port{firstPort}); err != nil {
+ t.Fatal(err)
+ }
+ var count int64
+ if err := Db.Model(&Port{}).Count(&count).Error; err != nil {
+ t.Fatal(err)
+ }
+ if count != 1 {
+ t.Fatalf("expected only one port after targeted deletion, got %d", count)
+ }
+}
+
+func TestChallengeIdentifiersAllowEmptyAndProtectActiveValues(t *testing.T) {
+ cleanup := setupSubmissionTestDB(t)
+ defer cleanup()
+
+ first := Challenge{Name: "empty-identifiers-a", Format: "static", AuthorID: 1}
+ second := Challenge{Name: "empty-identifiers-b", Format: "static", AuthorID: 1}
+ if err := Db.Create(&first).Error; err != nil {
+ t.Fatal(err)
+ }
+ if err := Db.Create(&second).Error; err != nil {
+ t.Fatalf("empty identifiers should be reusable: %v", err)
+ }
+
+ first.ContainerId = "container-id"
+ first.ImageId = "image-id"
+ if err := Db.Save(&first).Error; err != nil {
+ t.Fatal(err)
+ }
+ duplicate := Challenge{
+ Name: "duplicate-identifiers",
+ Format: "docker",
+ AuthorID: 1,
+ ContainerId: first.ContainerId,
+ ImageId: first.ImageId,
+ }
+ if err := Db.Create(&duplicate).Error; err == nil {
+ t.Fatal("active challenge identifiers must remain unique")
+ }
+
+ if err := Db.Delete(&first).Error; err != nil {
+ t.Fatal(err)
+ }
+ if err := Db.Create(&duplicate).Error; err != nil {
+ t.Fatalf("deleted challenge identifiers should be reusable: %v", err)
+ }
+}
+
+func TestSaveTransactionPreservesRepeatedActions(t *testing.T) {
+ cleanup := setupSubmissionTestDB(t)
+ defer cleanup()
+
+ user := createSubmissionTestUser(t, "audit-user")
+ challenge := createSubmissionTestChallenge(t, "audit-challenge", -1, false)
+ for range 2 {
+ if err := SaveTransaction(&Transaction{Action: "deploy", UserID: user.ID, ChallengeID: challenge.ID}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ var count int64
+ if err := Db.Model(&Transaction{}).Where("user_id = ? AND challenge_id = ? AND action = ?", user.ID, challenge.ID, "deploy").Count(&count).Error; err != nil {
+ t.Fatal(err)
+ }
+ if count != 2 {
+ t.Fatalf("transaction count = %d, want 2", count)
+ }
+}
diff --git a/core/database/submission_test.go b/core/database/submission_test.go
index d5750a46..6e74efbf 100644
--- a/core/database/submission_test.go
+++ b/core/database/submission_test.go
@@ -50,14 +50,20 @@ func setupSubmissionTestDB(t *testing.T) func() {
previousDB := Db
previousMux := DBMux
Db = testDB
- DBMux = &sync.Mutex{}
+ DBMux = &sync.RWMutex{}
- if err := Db.AutoMigrate(&Challenge{}, &User{}, &UserChallenges{}, &DynamicFlag{}, &DynamicFlagClaim{}, &DynamicScoreDirty{}); err != nil {
+ if err := Db.AutoMigrate(&Challenge{}, &Transaction{}, &Port{}, &Tag{}, &User{}, &UserChallenges{}, &ChallengeMaintainer{}, &DynamicFlag{}, &DynamicFlagClaim{}, &DynamicScoreDirty{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
if err := MigrateSubmissionGuards(); err != nil {
t.Fatalf("migrate submission guards: %v", err)
}
+ if err := MigratePortUniqueness(); err != nil {
+ t.Fatalf("migrate port uniqueness: %v", err)
+ }
+ if err := MigrateChallengeIdentifiers(); err != nil {
+ t.Fatalf("migrate challenge identifiers: %v", err)
+ }
return func() {
Db = previousDB
@@ -115,6 +121,8 @@ func createSubmissionTestChallenge(t *testing.T, name string, maxAttempts int, d
MinPoints: 100,
MaxAttemptLimit: maxAttempts,
Status: core.DEPLOY_STATUS["deployed"],
+ ContainerId: "container-" + name,
+ ImageId: "image-" + name,
}
if err := Db.Create(&challenge).Error; err != nil {
t.Fatalf("create challenge %s: %v", name, err)
diff --git a/core/database/tag.go b/core/database/tag.go
index 376e9061..75422669 100644
--- a/core/database/tag.go
+++ b/core/database/tag.go
@@ -38,10 +38,12 @@ func QueryRelatedChallenges(tag *Tag) ([]Challenge, error) {
var challenges []Challenge
var tagName Tag
- DBMux.Lock()
- defer DBMux.Unlock()
+ DBMux.RLock()
+ defer DBMux.RUnlock()
- Db.Where(&Tag{TagName: tag.TagName}).First(&tagName)
+ if err := Db.Where(&Tag{TagName: tag.TagName}).First(&tagName).Error; err != nil {
+ return nil, err
+ }
if err := Db.Preload("Tags").Preload("Ports").Model(&tagName).Association("Challenges").Find(&challenges); err != nil {
return challenges, err
@@ -55,10 +57,12 @@ func QueryRelatedChallengesMetadata(tag *Tag) ([]Challenge, error) {
var challenges []Challenge
var tagName Tag
- DBMux.Lock()
- defer DBMux.Unlock()
+ DBMux.RLock()
+ defer DBMux.RUnlock()
- Db.Where(&Tag{TagName: tag.TagName}).First(&tagName)
+ if err := Db.Where(&Tag{TagName: tag.TagName}).First(&tagName).Error; err != nil {
+ return nil, err
+ }
if err := Db.Model(&tagName).
Select("id", "name", "created_at", "points", "difficulty", "instanced", "instance_expiration", "status").
@@ -75,8 +79,8 @@ func QueryRelatedChallengesMetadata(tag *Tag) ([]Challenge, error) {
func QueryTags(whereMap map[string]interface{}) ([]*Tag, error) {
var tags []*Tag
- DBMux.Lock()
- defer DBMux.Unlock()
+ DBMux.RLock()
+ defer DBMux.RUnlock()
tx := Db.Where(whereMap).Find(&tags)
if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
@@ -91,29 +95,19 @@ func QueryTags(whereMap map[string]interface{}) ([]*Tag, error) {
}
// Update tags
-func UpdateTags(tag []*Tag, chall *Challenge) error {
- var tags []Tag
-
+func UpdateTags(tagEntries []*Tag, chall *Challenge) error {
DBMux.Lock()
defer DBMux.Unlock()
- // Delete existing tags
- Db.Model(&chall).Association("Tags").Find(&tags)
- tx := Db.Begin()
- if err := tx.Model(&chall).Association("Tags").Delete(tags); err != nil {
- return err
- }
-
- // Create tags
- for _, tagEntry := range tag {
- if err := tx.FirstOrCreate(tagEntry, *tagEntry).Error; err != nil {
- tx.Rollback()
- return err
+ return Db.Transaction(func(tx *gorm.DB) error {
+ for _, tagEntry := range tagEntries {
+ if tagEntry == nil || tagEntry.TagName == "" {
+ return errors.New("tag name is required")
+ }
+ if err := tx.FirstOrCreate(tagEntry, Tag{TagName: tagEntry.TagName}).Error; err != nil {
+ return err
+ }
}
- if err := tx.Model(&chall).Association("Tags").Append(tagEntry); err != nil {
- return err
- }
- }
-
- return tx.Commit().Error
+ return tx.Model(chall).Association("Tags").Replace(tagEntries)
+ })
}
diff --git a/core/database/transactions.go b/core/database/transactions.go
index f1602335..6ae45337 100644
--- a/core/database/transactions.go
+++ b/core/database/transactions.go
@@ -1,6 +1,7 @@
package database
import (
+ "errors"
"fmt"
"github.com/jinzhu/gorm"
@@ -16,18 +17,14 @@ type Transaction struct {
}
func SaveTransaction(transaction *Transaction) error {
+ if transaction == nil || transaction.UserID == 0 || transaction.ChallengeID == 0 || transaction.Action == "" {
+ return errors.New("complete transaction audit record is required")
+ }
DBMux.Lock()
defer DBMux.Unlock()
- tx := Db.Begin()
-
- if tx.Error != nil {
- return fmt.Errorf("Error while saving transaction: %v", tx.Error)
- }
-
- if err := tx.FirstOrCreate(transaction, *transaction).Error; err != nil {
- tx.Rollback()
- return err
+ if err := Db.Create(transaction).Error; err != nil {
+ return fmt.Errorf("save transaction audit record: %w", err)
}
- return tx.Commit().Error
+ return nil
}
diff --git a/core/database/user.go b/core/database/user.go
index 476a249f..a3a5baf6 100644
--- a/core/database/user.go
+++ b/core/database/user.go
@@ -1,22 +1,12 @@
package database
import (
- "bytes"
- "crypto/sha256"
"errors"
"fmt"
- "html/template"
- "io/ioutil"
- "os"
- "path/filepath"
- "regexp"
- "strconv"
"time"
"github.com/sdslabs/beastv4/core"
- "github.com/sdslabs/beastv4/core/config"
"github.com/sdslabs/beastv4/pkg/auth"
- tools "github.com/sdslabs/beastv4/templates"
log "github.com/sirupsen/logrus"
"gorm.io/gorm"
)
@@ -25,24 +15,27 @@ type User struct {
gorm.Model
auth.AuthModel
- Challenges []*Challenge `gorm:"many2many:user_challenges;"`
+ Challenges []*Challenge `gorm:"many2many:challenge_maintainers;"`
Name string `gorm:"not null"`
Email string `gorm:"non null;unique"`
- SshKey string
- Status uint `gorm:"not null;default:0"` // 0 for unbanned, 1 for banned
- Score uint `gorm:"default:0"`
- FrozenScore uint `gorm:"default:0"`
- Hints []*Hint `gorm:"many2many:user_hints;references:HintID;joinReferences:HintID"`
+ Status uint `gorm:"not null;default:0"` // 0 for unbanned, 1 for banned
+ Score uint `gorm:"default:0"`
+ FrozenScore uint `gorm:"default:0"`
+ Hints []*Hint `gorm:"many2many:user_hints;references:HintID;joinReferences:HintID"`
}
// Queries all the users entries where the column represented by key
// have the value in value.
func QueryUserEntries(key string, value string) ([]User, error) {
- queryKey := fmt.Sprintf("%s = ?", key)
+ column, err := validatedQueryColumn(key, "id", "username", "email", "role")
+ if err != nil {
+ return nil, err
+ }
+ queryKey := fmt.Sprintf("%s = ?", column)
var users []User
- DBMux.Lock()
- defer DBMux.Unlock()
+ DBMux.RLock()
+ defer DBMux.RUnlock()
tx := Db.Where(queryKey, value).Find(&users)
if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
@@ -60,8 +53,8 @@ func QueryUserEntries(key string, value string) ([]User, error) {
func QueryAllUsers() ([]User, error) {
var users []User
- DBMux.Lock()
- defer DBMux.Unlock()
+ DBMux.RLock()
+ defer DBMux.RUnlock()
tx := Db.Find(&users)
if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
@@ -74,12 +67,12 @@ func QueryAllUsers() ([]User, error) {
func QueryUserById(authorID uint) (User, error) {
var user User
- DBMux.Lock()
- defer DBMux.Unlock()
+ DBMux.RLock()
+ defer DBMux.RUnlock()
tx := Db.First(&user, authorID)
if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
- return User{}, nil
+ return User{}, gorm.ErrRecordNotFound
}
return user, tx.Error
@@ -90,8 +83,8 @@ func GetUserRank(userID uint, userScore uint, updatedAt time.Time) (rank int64,
rank = 1
- DBMux.Lock()
- defer DBMux.Unlock()
+ DBMux.RLock()
+ defer DBMux.RUnlock()
tx := Db.Where("id != ? AND score >= ? AND role = ? AND status = ?", userID, userScore, core.USER_ROLES["contestant"], 0).Find(&users)
@@ -115,7 +108,7 @@ func QueryFirstUserEntry(key string, value string) (User, error) {
}
if len(users) == 0 {
- return User{}, nil
+ return User{}, gorm.ErrRecordNotFound
}
return users[0], nil
@@ -125,20 +118,12 @@ func QueryFirstUserEntry(key string, value string) (User, error) {
// It returns an error if anything wrong happen during the
// transaction.
func CreateUserEntry(user *User) error {
+ if user == nil {
+ return errors.New("user is required")
+ }
DBMux.Lock()
defer DBMux.Unlock()
- tx := Db.Begin()
-
- if tx.Error != nil {
- return fmt.Errorf("Error while starting transaction: %v", tx.Error)
- }
-
- if err := tx.FirstOrCreate(user, *user).Error; err != nil {
- tx.Rollback()
- return err
- }
-
- return tx.Commit().Error
+ return Db.Create(user).Error
}
// Update an entry for the user in the User table
@@ -154,8 +139,8 @@ func UpdateUser(user *User, m map[string]interface{}) error {
func GetRelatedChallenges(user *User) ([]Challenge, error) {
var challenges []Challenge
- DBMux.Lock()
- defer DBMux.Unlock()
+ DBMux.RLock()
+ defer DBMux.RUnlock()
if err := Db.Preload("Tags").Model(user).Association("Challenges").Find(&challenges); err != nil {
return challenges, err
@@ -164,6 +149,16 @@ func GetRelatedChallenges(user *User) ([]Challenge, error) {
return challenges, nil
}
+func IsChallengeMaintainer(userID, challengeID uint) (bool, error) {
+ var count int64
+ DBMux.RLock()
+ defer DBMux.RUnlock()
+ err := Db.Table("challenge_maintainers").
+ Where("user_id = ? AND challenge_id = ?", userID, challengeID).
+ Count(&count).Error
+ return count > 0, err
+}
+
// UserSolvedChallenge represents a challenge solved by a user with the actual solve timestamp
type UserSolvedChallenge struct {
ChallengeID uint
@@ -186,8 +181,8 @@ func GetUserSolvedChallenges(userID uint) ([]UserSolvedChallenge, error) {
}
var rows []solveRow
- DBMux.Lock()
- defer DBMux.Unlock()
+ DBMux.RLock()
+ defer DBMux.RUnlock()
err := Db.Table("user_challenges").
Select("DISTINCT ON (user_challenges.challenge_id) user_challenges.challenge_id, challenges.name, challenges.type, challenges.points, user_challenges.created_at as solved_at").
@@ -253,8 +248,8 @@ func CheckPreviousSubmissions(userId uint, challId uint) (bool, error) {
var count int64
count = 0
- DBMux.Lock()
- defer DBMux.Unlock()
+ DBMux.RLock()
+ defer DBMux.RUnlock()
tx := Db.Where("user_id = ? AND challenge_id = ? AND solved = ?", userId, challId, true).Find(&userChallenges).Count(&count)
@@ -265,136 +260,11 @@ func CheckPreviousSubmissions(userId uint, challId uint) (bool, error) {
return (count >= 1), tx.Error
}
-// hook after create
-func (user *User) AfterCreate(tx *gorm.DB) error {
- if user.SshKey == "" {
- return nil
- }
- if err := addToAuthorizedKeys(user); err != nil {
- return fmt.Errorf("Error while adding userized_keys : %s", err)
- }
- return nil
-}
-
-// hook after update
-func (user *User) AfterUpdate(tx *gorm.DB) error {
- iFace, _ := tx.InstanceGet("gorm:update_attrs")
- if iFace == nil {
- return nil
- }
- updatedAttr := iFace.(map[string]interface{})
- if _, ok := updatedAttr["ssh_key"]; ok {
- err := deleteFromAuthorizedKeys(user)
- if err != nil {
- return fmt.Errorf("Error while deleting from userized_keys : %s", err)
- }
- if user.SshKey == "" {
- return nil
- }
- err = addToAuthorizedKeys(user)
- if err != nil {
- return fmt.Errorf("Error while adding userized_keys : %s", err)
- }
- err = updateScript(user)
- if err != nil {
- return fmt.Errorf("Error while updating script : %s", err)
- }
- }
- return nil
-}
-
-// Updating data in same transaction
-func (user *User) AfterDelete(tx *gorm.DB) error {
- err := deleteFromAuthorizedKeys(user)
- return err
-}
-
-type AuthorizedKeyTemplate struct {
- UserID string
- Command string
- PubKey string
-}
-
-func generateContentAuthorizedKeyFile(user *User) ([]byte, error) {
- SHA256 := sha256.New()
- SHA256.Write([]byte(user.Email))
- scriptPath := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_SCRIPTS_DIR, fmt.Sprintf("%x", SHA256.Sum(nil)))
-
- data := AuthorizedKeyTemplate{
- UserID: strconv.Itoa(int(user.Model.ID)),
- Command: scriptPath,
- PubKey: user.SshKey,
- }
-
- var authKey bytes.Buffer
- authKeyTemplate, err := template.New("authKey").Parse(tools.AUTHORIZED_KEY_TEMPLATE)
- if err != nil {
- return []byte(""), fmt.Errorf("Error while parsing script template :: %s", err)
- }
-
- err = authKeyTemplate.Execute(&authKey, data)
- if err != nil {
- return []byte(""), fmt.Errorf("Error while executing script template :: %s", err)
- }
-
- return authKey.Bytes(), nil
-}
-
-// adds to authorized keys
-func addToAuthorizedKeys(user *User) error {
- if config.Cfg == nil {
- log.Warn("No config initialized, skipping add to authorized keys hook")
- return nil
- }
-
- f, err := os.OpenFile(config.Cfg.AuthorizedKeysFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
- if err != nil {
- return fmt.Errorf("Error while opening userized keys file : %s", err)
- }
- defer f.Close()
-
- authBytes, err := generateContentAuthorizedKeyFile(user)
- if err != nil {
- return err
- }
-
- authBytes = bytes.Replace(authBytes, []byte("+"), []byte("+"), -1)
-
- if _, err := f.Write(authBytes); err != nil {
- return fmt.Errorf("Error while appending key to userized keys file : %s", err)
- }
- return nil
-}
-
-func deleteFromAuthorizedKeys(user *User) error {
-
- if config.Cfg == nil {
- log.Warn("Config is not initialized, skipping delete from auth keys hook")
- return nil
- }
-
- keys, err := ioutil.ReadFile(config.Cfg.AuthorizedKeysFile)
- if err != nil {
- return fmt.Errorf("Error while reading auth file : %s", err)
- }
-
- regex := "(?m)[\r\n]+^.*\"SSH_USER=" + strconv.Itoa(int(user.ID)) + "\".*$"
-
- re := regexp.MustCompile(regex)
- newKeys := []byte(re.ReplaceAllString(string(keys), ""))
-
- err = ioutil.WriteFile(config.Cfg.AuthorizedKeysFile, newKeys, 0644)
- if err != nil {
- return fmt.Errorf("Error while writing to auth file : %s", err)
- }
- return nil
-}
-
func QueryTopUsersByScore(limit int) ([]User, error) {
var users []User
- DBMux.Lock()
- defer DBMux.Unlock()
+ DBMux.RLock()
+ defer DBMux.RUnlock()
tx := Db.Where("role = ? AND status = ?", core.USER_ROLES["contestant"], 0).
Order("score desc, updated_at asc").
@@ -411,8 +281,8 @@ func QueryTopUsersByScore(limit int) ([]User, error) {
func QueryUsersByScoreOffsetLimit(limit, offset int) ([]User, error) {
var users []User
- DBMux.Lock()
- defer DBMux.Unlock()
+ DBMux.RLock()
+ defer DBMux.RUnlock()
tx := Db.Where("role = ? AND status = ?", core.USER_ROLES["contestant"], 0).
Order("score desc, updated_at asc").
@@ -430,8 +300,8 @@ func QueryUsersByScoreOffsetLimit(limit, offset int) ([]User, error) {
func QueryTopUsersByFrozenScore(limit int) ([]User, error) {
var users []User
- DBMux.Lock()
- defer DBMux.Unlock()
+ DBMux.RLock()
+ defer DBMux.RUnlock()
tx := Db.Where("role = ? AND status = ?", core.USER_ROLES["contestant"], 0).
Order("frozen_score desc, updated_at asc").
@@ -448,8 +318,8 @@ func QueryTopUsersByFrozenScore(limit int) ([]User, error) {
func QueryUsersByFrozenScoreOffsetLimit(limit, offset int) ([]User, error) {
var users []User
- DBMux.Lock()
- defer DBMux.Unlock()
+ DBMux.RLock()
+ defer DBMux.RUnlock()
tx := Db.Where("role = ? AND status = ?", core.USER_ROLES["contestant"], 0).
Order("frozen_score desc, updated_at asc").
@@ -478,8 +348,8 @@ func ResetFrozenScores() error {
func IsFrozenScoreSet() (bool, error) {
var count int64
- DBMux.Lock()
- defer DBMux.Unlock()
+ DBMux.RLock()
+ defer DBMux.RUnlock()
err := Db.Model(&User{}).Where("frozen_score != 0").Count(&count).Error
if err != nil {
return false, err
@@ -489,8 +359,8 @@ func IsFrozenScoreSet() (bool, error) {
func GetUserCount() (int64, error) {
var count int64
- DBMux.Lock()
- defer DBMux.Unlock()
+ DBMux.RLock()
+ defer DBMux.RUnlock()
tx := Db.Model(&User{}).Where("role = ?", "contestant").Count(&count)
if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
return 0, nil
@@ -500,10 +370,10 @@ func GetUserCount() (int64, error) {
func QueryAllUniqueTags() ([]string, error) {
var tags []string
- DBMux.Lock()
- defer DBMux.Unlock()
+ DBMux.RLock()
+ defer DBMux.RUnlock()
- tx := Db.Model(&Challenge{}).Distinct().Pluck("tag", &tags)
+ tx := Db.Model(&Tag{}).Distinct().Order("tag_name").Pluck("tag_name", &tags)
if tx.Error != nil {
return nil, tx.Error
}
diff --git a/core/manager/archive_test.go b/core/manager/archive_test.go
new file mode 100644
index 00000000..c8fef6e2
--- /dev/null
+++ b/core/manager/archive_test.go
@@ -0,0 +1,107 @@
+package manager
+
+import (
+ "archive/zip"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+type archiveEntry struct {
+ name string
+ mode os.FileMode
+ body string
+}
+
+func writeTestArchive(t *testing.T, entries []archiveEntry) string {
+ t.Helper()
+ archivePath := filepath.Join(t.TempDir(), "challenge.zip")
+ file, err := os.Create(archivePath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ writer := zip.NewWriter(file)
+ for _, entry := range entries {
+ header := &zip.FileHeader{Name: entry.name, Method: zip.Deflate}
+ header.SetMode(entry.mode)
+ part, err := writer.CreateHeader(header)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := part.Write([]byte(entry.body)); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := writer.Close(); err != nil {
+ t.Fatal(err)
+ }
+ if err := file.Close(); err != nil {
+ t.Fatal(err)
+ }
+ return archivePath
+}
+
+func TestUnzipChallengeFolderExtractsRegularFiles(t *testing.T) {
+ archivePath := writeTestArchive(t, []archiveEntry{{
+ name: "challenge/beast.toml",
+ mode: 0600,
+ body: "[challenge]",
+ }})
+ destination := t.TempDir()
+
+ extracted, err := UnzipChallengeFolder(archivePath, destination)
+ if err != nil {
+ t.Fatal(err)
+ }
+ contents, err := os.ReadFile(filepath.Join(extracted, "challenge", "beast.toml"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(contents) != "[challenge]" {
+ t.Fatalf("unexpected contents: %q", contents)
+ }
+}
+
+func TestUnzipChallengeFolderRejectsUnsafeEntries(t *testing.T) {
+ tests := []archiveEntry{
+ {name: "../escape", mode: 0600, body: "bad"},
+ {name: "/absolute", mode: 0600, body: "bad"},
+ {name: `windows\\escape`, mode: 0600, body: "bad"},
+ {name: "link", mode: os.ModeSymlink | 0777, body: "target"},
+ }
+ for _, entry := range tests {
+ t.Run(entry.name, func(t *testing.T) {
+ archivePath := writeTestArchive(t, []archiveEntry{entry})
+ if _, err := UnzipChallengeFolder(archivePath, t.TempDir()); err == nil {
+ t.Fatal("expected unsafe archive entry to be rejected")
+ }
+ })
+ }
+}
+
+func TestUnzipChallengeFolderRejectsDuplicatePaths(t *testing.T) {
+ archivePath := writeTestArchive(t, []archiveEntry{
+ {name: "challenge/flag", mode: 0600, body: "first"},
+ {name: "challenge/flag", mode: 0600, body: "second"},
+ })
+ if _, err := UnzipChallengeFolder(archivePath, t.TempDir()); err == nil {
+ t.Fatal("expected duplicate archive path to be rejected")
+ }
+}
+
+func TestCopyDirRejectsSymlinksAndExistingDestinations(t *testing.T) {
+ source := t.TempDir()
+ if err := os.WriteFile(filepath.Join(source, "file"), []byte("content"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink("file", filepath.Join(source, "link")); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := CopyDir(source, filepath.Join(t.TempDir(), "copy")); err == nil {
+ t.Fatal("expected symbolic link to be rejected")
+ }
+ if err := CopyDir(source, t.TempDir()); err == nil {
+ t.Fatal("expected existing destination to be rejected")
+ }
+}
diff --git a/core/manager/challenge.go b/core/manager/challenge.go
index 70680bab..6bb2f4aa 100644
--- a/core/manager/challenge.go
+++ b/core/manager/challenge.go
@@ -7,7 +7,6 @@ import (
"path/filepath"
"strings"
- "github.com/BurntSushi/toml"
containerType "github.com/docker/docker/api/types"
_ "github.com/jinzhu/gorm/dialects/sqlite"
"github.com/sdslabs/beastv4/core"
@@ -82,23 +81,29 @@ func (worker *Worker) PerformTask(w wpool.Task) *wpool.Task {
info := w.Info.(TaskInfo)
switch info.Action {
case core.MANAGE_ACTION_DEPLOY:
- StartDeployPipeline(info.ChallDir, info.SkipStage, info.SkipCommit, info.NoCache)
+ if err := StartDeployPipeline(info.ChallDir, info.SkipStage, info.SkipCommit, info.NoCache); err != nil {
+ log.Errorf("Error while deploying challenge(%s): %s", w.ID, err)
+ Q.RecordError(fmt.Errorf("deploy %s: %w", w.ID, err))
+ }
case core.MANAGE_ACTION_UNDEPLOY:
err := StartUndeployChallenge(w.ID, false)
if err != nil {
log.Errorf("Error while undeplying challenge(%s): %s", w.ID, err.Error())
+ Q.RecordError(fmt.Errorf("undeploy %s: %w", w.ID, err))
}
case core.MANAGE_ACTION_REDEPLOY:
err := StartUndeployChallenge(w.ID, true)
if err != nil {
log.Errorf("Error while redeplying challenge(%s): %s", w.ID, err.Error())
+ Q.RecordError(fmt.Errorf("redeploy %s: %w", w.ID, err))
return nil
}
work, err := GetDeployWork(w.ID)
if err != nil {
log.Error(err)
+ Q.RecordError(fmt.Errorf("prepare redeploy %s: %w", w.ID, err))
return nil
}
return work
@@ -107,17 +112,20 @@ func (worker *Worker) PerformTask(w wpool.Task) *wpool.Task {
err := StartUndeployChallenge(w.ID, true)
if err != nil {
log.Errorf("Error while purging challenge(%s): %s", w.ID, err.Error())
+ Q.RecordError(fmt.Errorf("purge %s: %w", w.ID, err))
}
default:
chall, err := database.QueryFirstChallengeEntry("name", w.ID)
if err != nil {
log.Errorf("DB_ACCESS_ERROR : %s", err.Error())
+ Q.RecordError(fmt.Errorf("query challenge %s: %w", w.ID, err))
}
if chall.Name != "" {
database.UpdateChallenge(&chall, map[string]interface{}{"status": core.DEPLOY_STATUS["undeployed"]})
log.Errorf("The action(%s) specified for challenge : %s does not exist", info.Action, w.ID)
+ Q.RecordError(fmt.Errorf("action %s does not exist", info.Action))
}
}
@@ -726,8 +734,8 @@ func undeployChallenge(challengeName string, purge bool) error {
// and then remove the challenge from the staging directory.
if purge {
configFile := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_STAGING_DIR, challengeName, core.CHALLENGE_CONFIG_FILE_NAME)
- var cfg config.BeastChallengeConfig
- _, err = toml.DecodeFile(configFile, &cfg)
+ cfg, loadErr := config.LoadChallengeConfig(configFile)
+ err = loadErr
if err != nil {
return err
}
diff --git a/core/manager/health_check.go b/core/manager/health_check.go
index 37cfeac2..0ce22e7c 100644
--- a/core/manager/health_check.go
+++ b/core/manager/health_check.go
@@ -2,6 +2,7 @@ package manager
import (
"context"
+ "errors"
"fmt"
"path/filepath"
"strings"
@@ -20,11 +21,7 @@ import (
log "github.com/sirupsen/logrus"
)
-var HEALTH_CHECKER = false
-var (
- instanceCleanupOnce sync.Once
- instanceExpirySubscriberOnce sync.Once
-)
+var instanceDeletionMutex sync.Mutex
// Check for static challenegs' assets to be present on staging server.
// At the time of writing, Beast deploys assets to localhost only.
@@ -158,66 +155,92 @@ func ServerHealthProber(waitTime int) {
}
}
-func BeastHeathCheckProber(waitTime int) {
- if !HEALTH_CHECKER {
- log.Info("Starting Health Check prober.")
- HEALTH_CHECKER = true
-
- go InstanceCleanupProber()
+func runHealthCheckCycle(waitTime int) {
+ var group sync.WaitGroup
+ checks := []func(){
+ func() { ChallengesHealthProber(waitTime) },
+ func() { ServerHealthProber(waitTime) },
+ }
+ group.Add(len(checks))
+ for _, check := range checks {
+ go func(check func()) {
+ defer group.Done()
+ check()
+ }(check)
+ }
+ group.Wait()
+}
- for {
- go ChallengesHealthProber(waitTime)
- go ServerHealthProber(waitTime)
- go database.BackupDatabase()
- go cache.BackupCache()
- time.Sleep(time.Duration(waitTime) * time.Second)
+func BeastHealthCheckProber(ctx context.Context, waitTime int) {
+ if waitTime <= 0 {
+ log.Error("Health check interval must be positive")
+ return
+ }
+ if ctx.Err() != nil {
+ return
+ }
+ log.Info("Starting Health Check prober.")
+ ticker := time.NewTicker(time.Duration(waitTime) * time.Second)
+ defer ticker.Stop()
+ for {
+ if ctx.Err() != nil {
+ return
+ }
+ runHealthCheckCycle(waitTime)
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
}
- } else {
- log.Warn("Health Checker Already Running. Not Starting Again")
}
}
-func InstanceCleanupProber() {
- started := false
- instanceCleanupOnce.Do(func() {
- started = true
- })
- if !started {
- log.Warn("Instance cleanup prober already running. Not starting again")
+func InstanceCleanupProber(ctx context.Context) {
+ if ctx.Err() != nil {
return
}
-
log.Info("Starting Instance Cleanup prober with event-driven expiry and reconciliation interval: ", core.DEFAULT_HEALTH_CHECK_TIME)
- startInstanceExpirySubscriber()
+ subscriberDone := startInstanceExpirySubscriber(ctx)
+ defer func() { <-subscriberDone }()
+ ticker := time.NewTicker(core.DEFAULT_HEALTH_CHECK_TIME)
+ defer ticker.Stop()
for {
+ if ctx.Err() != nil {
+ return
+ }
ProcessInstanceDeletionQueue()
CleanupOrphanedInstanceContainers()
QueueExpiredInstances()
- time.Sleep(core.DEFAULT_HEALTH_CHECK_TIME)
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ }
}
}
-func startInstanceExpirySubscriber() {
- instanceExpirySubscriberOnce.Do(func() {
- if err := cache.EnableKeyspaceExpiryNotifications(); err != nil {
- log.Warnf("Redis keyspace expiry notifications unavailable, relying on reconciliation: %v", err)
- }
+func startInstanceExpirySubscriber(ctx context.Context) <-chan struct{} {
+ done := make(chan struct{})
+ if err := cache.EnableKeyspaceExpiryNotifications(); err != nil {
+ log.Warnf("Redis keyspace expiry notifications unavailable, relying on reconciliation: %v", err)
+ }
- go func() {
- err := cache.SubscribeExpiredInstanceMarkers(context.Background(), func(instanceID string) {
- log.Infof("Instance expiry marker fired for %s, queueing cleanup", instanceID)
- if err := cache.QueueInstanceForDeletion(instanceID); err != nil {
- log.Warnf("Failed to queue expired instance %s from Redis event: %v", instanceID, err)
- return
- }
- ProcessInstanceDeletionQueue()
- })
- if err != nil {
- log.Warnf("Redis expiry subscriber stopped, reconciliation will continue cleanup: %v", err)
+ go func() {
+ defer close(done)
+ err := cache.SubscribeExpiredInstanceMarkers(ctx, func(instanceID string) {
+ log.Infof("Instance expiry marker fired for %s, queueing cleanup", instanceID)
+ if err := cache.QueueInstanceForDeletion(instanceID); err != nil {
+ log.Warnf("Failed to queue expired instance %s from Redis event: %v", instanceID, err)
+ return
}
- }()
- })
+ ProcessInstanceDeletionQueue()
+ })
+ if err != nil && !errors.Is(err, context.Canceled) {
+ log.Warnf("Redis expiry subscriber stopped, reconciliation will continue cleanup: %v", err)
+ }
+ }()
+ return done
}
func QueueExpiredInstances() {
@@ -241,6 +264,8 @@ func QueueExpiredInstances() {
}
func ProcessInstanceDeletionQueue() {
+ instanceDeletionMutex.Lock()
+ defer instanceDeletionMutex.Unlock()
log.Debug("Processing instance deletion queue")
for i := 0; i < 10; i++ {
diff --git a/core/manager/health_check_test.go b/core/manager/health_check_test.go
new file mode 100644
index 00000000..04a752e9
--- /dev/null
+++ b/core/manager/health_check_test.go
@@ -0,0 +1,29 @@
+package manager
+
+import (
+ "context"
+ "testing"
+
+ "github.com/sdslabs/beastv4/core/config"
+)
+
+func TestBackgroundProbersRespectCancelledContext(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+
+ BeastHealthCheckProber(ctx, 1)
+ InstanceCleanupProber(ctx)
+}
+
+func TestActiveLocalServerNameSupportsConfiguredAlias(t *testing.T) {
+ previous := config.Cfg
+ config.Cfg = &config.BeastConfig{AvailableServers: map[string]config.AvailableServer{
+ "worker-local": {Host: "127.0.0.1", Active: true},
+ }}
+ defer func() { config.Cfg = previous }()
+
+ name, ok := activeLocalServerName()
+ if !ok || name != "worker-local" {
+ t.Fatalf("activeLocalServerName() = %q, %t", name, ok)
+ }
+}
diff --git a/core/manager/instance.go b/core/manager/instance.go
index 9e0cfdfe..a85a8f47 100644
--- a/core/manager/instance.go
+++ b/core/manager/instance.go
@@ -5,7 +5,6 @@ import (
"path/filepath"
"time"
- "github.com/BurntSushi/toml"
"github.com/google/uuid"
"github.com/sdslabs/beastv4/core"
"github.com/sdslabs/beastv4/core/cache"
@@ -42,13 +41,14 @@ func SpawnInstance(challengeName, userID, username string) (*cache.Instance, err
challengeStagingDir := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_STAGING_DIR, challengeName)
configFile := filepath.Join(challengeStagingDir, core.CHALLENGE_CONFIG_FILE_NAME)
- var config cfg.BeastChallengeConfig
- _, err = toml.DecodeFile(configFile, &config)
+ config, err := cfg.LoadChallengeConfig(configFile)
if err != nil {
return nil, fmt.Errorf("failed to load challenge config: %w", err)
}
- config.Resources.ValidateRequiredFields()
+ if err := config.Resources.ValidateRequiredFields(); err != nil {
+ return nil, fmt.Errorf("invalid challenge resource limits: %w", err)
+ }
if !config.Challenge.Metadata.IsInstanced() {
return nil, fmt.Errorf("challenge %s is not configured for instancing", challengeName)
diff --git a/core/manager/pipeline.go b/core/manager/pipeline.go
index 08a257f8..32448199 100644
--- a/core/manager/pipeline.go
+++ b/core/manager/pipeline.go
@@ -5,7 +5,6 @@ import (
"fmt"
"os"
"path/filepath"
- "time"
"github.com/sdslabs/beastv4/core"
cfg "github.com/sdslabs/beastv4/core/config"
@@ -16,7 +15,6 @@ import (
"github.com/sdslabs/beastv4/pkg/remoteManager"
"github.com/sdslabs/beastv4/utils"
- "github.com/BurntSushi/toml"
log "github.com/sirupsen/logrus"
)
@@ -121,6 +119,9 @@ func stageChallenge(challengeDir string, config *cfg.BeastChallengeConfig) error
}
log.Debugf("Copying challenge config to staging directory")
+ if err := utils.RemoveFileIfExists(filepath.Join(stagingDir, core.CHALLENGE_CONFIG_FILE_NAME)); err != nil {
+ return fmt.Errorf("remove previous staged challenge config: %w", err)
+ }
err = utils.CopyFile(challengeConfig, filepath.Join(stagingDir, core.CHALLENGE_CONFIG_FILE_NAME))
if err != nil {
return fmt.Errorf("error while copying challenge config to staging : %s", err)
@@ -188,7 +189,12 @@ func commitChallenge(challenge *database.Challenge, config cfg.BeastChallengeCon
} else {
if cfg.Cfg.UseLocalDockerDaemon(challenge.ServerDeployed) {
var buff *bytes.Buffer
- buff, imageId, buildErr = cr.BuildImageFromTarContext(challengeName, challengeTag, stagedPath, config.Challenge.Env.DockerCtx, noCache)
+ buff, imageId, buildErr = cr.BuildImageFromTarContext(challengeName, challengeTag, stagedPath, config.Challenge.Env.DockerCtx, noCache, cr.BuildLimits{
+ CPUShares: config.Resources.CPUShares,
+ CPUs: config.Resources.CPUsLimit,
+ Memory: config.Resources.Memory,
+ Pids: config.Resources.PidsLimit,
+ })
if buff != nil {
logBytes = buff.Bytes()
} else {
@@ -202,24 +208,34 @@ func commitChallenge(challenge *database.Challenge, config cfg.BeastChallengeCon
if err != nil {
return fmt.Errorf("error while checking if the challenge is staged on the remote server")
}
- logBytes, imageId, buildErr = remoteManager.BuildImageFromTarContextRemote(challengeName, challengeTag, remoteStagedPath, server)
+ logBytes, imageId, buildErr = remoteManager.BuildImageFromTarContextRemote(challengeName, challengeTag, remoteStagedPath, server, cr.BuildLimits{
+ CPUShares: config.Resources.CPUShares,
+ CPUs: config.Resources.CPUsLimit,
+ Memory: config.Resources.Memory,
+ Pids: config.Resources.PidsLimit,
+ })
}
}
// Create logs directory for the challenge in staging directory.
challengeStagingLogsDir := filepath.Join(challengeStagingDir, core.BEAST_CHALLENGE_LOGS_DIR)
- err = utils.CreateIfNotExistDir(challengeStagingLogsDir)
+ err = os.MkdirAll(challengeStagingLogsDir, 0700)
if err != nil {
log.Errorf("Could not create challenge logs directory : %s : %s", challengeStagingLogsDir, err)
} else if logBytes != nil {
- logFilePath := filepath.Join(challengeStagingLogsDir, fmt.Sprintf("%s.%s.log", challengeName, time.Now().Format("20060102150405")))
- logFile, err := os.OpenFile(logFilePath, os.O_WRONLY|os.O_TRUNC|os.O_CREATE, 0755)
+ if err := os.Chmod(challengeStagingLogsDir, 0700); err != nil {
+ return fmt.Errorf("secure build log directory: %w", err)
+ }
+ logFile, err := os.CreateTemp(challengeStagingLogsDir, challengeName+".*.log")
if err != nil {
- log.Errorf("Error while writing logs to file : %s", logFilePath)
- return fmt.Errorf("error logs generated on image build failure could not be written to the logfile")
+ return fmt.Errorf("create build log: %w", err)
+ }
+ if _, err := logFile.Write(logBytes); err != nil {
+ _ = logFile.Close()
+ return fmt.Errorf("write build log: %w", err)
+ }
+ if err := logFile.Close(); err != nil {
+ return fmt.Errorf("close build log: %w", err)
}
- defer logFile.Close()
-
- logFile.Write(logBytes)
log.Debug("Logs written to log file for the challenge")
}
@@ -440,8 +456,7 @@ func bootstrapDeployPipeline(challengeDir string, skipStage bool, skipCommit boo
challengeName := filepath.Base(challengeDir)
configFile := filepath.Join(challengeDir, core.CHALLENGE_CONFIG_FILE_NAME)
- var config cfg.BeastChallengeConfig
- _, err := toml.DecodeFile(configFile, &config)
+ config, err := cfg.LoadChallengeConfig(configFile)
if err != nil {
log.Errorf("Error while loading beast config for challenge %s : %s", challengeName, err)
return fmt.Errorf("CONFIG ERROR: %s : %s", challengeName, err)
@@ -464,7 +479,7 @@ func bootstrapDeployPipeline(challengeDir string, skipStage bool, skipCommit boo
return fmt.Errorf("CONFIG ERROR: %s : Inconsistent configuration name and challengeName", challengeName)
}
- challenge, err := database.QueryFirstChallengeEntry("name", config.Challenge.Metadata.Name)
+ challenge, _, err := database.FindFirstChallengeEntry("name", config.Challenge.Metadata.Name)
if err != nil {
log.Errorf("Error while querying challenge %s : %s", config.Challenge.Metadata.Name, err)
return fmt.Errorf("DB ERROR: %s : %s", challengeName, err)
@@ -595,13 +610,16 @@ func bootstrapDeployPipeline(challengeDir string, skipStage bool, skipCommit boo
// This is just a decorator function over bootstrapDeployPipeline and generate
// notifications to slack on the basis of the result of the deploy pipeline.
-func StartDeployPipeline(challengeDir string, skipStage bool, skipCommit bool, noCache bool) {
+func StartDeployPipeline(challengeDir string, skipStage bool, skipCommit bool, noCache bool) error {
challengeName := filepath.Base(challengeDir)
var sendNotificationError error
err := bootstrapDeployPipeline(challengeDir, skipStage, skipCommit, noCache)
if err != nil {
- sendNotificationError = notify.SendNotification(notify.Error, err.Error())
+ if notificationErr := notify.SendNotification(notify.Error, err.Error()); notificationErr != nil {
+ log.Warnf("%s: failure notification could not be sent: %v", challengeName, notificationErr)
+ }
+ return err
} else {
msg := fmt.Sprintf("DEPLOY SUCCESS : %s : Challenge deployment pipeline successful.", challengeName)
sendNotificationError = notify.SendNotification(notify.Success, msg)
@@ -610,4 +628,5 @@ func StartDeployPipeline(challengeDir string, skipStage bool, skipCommit bool, n
if sendNotificationError == nil {
log.Debugf("%s: Notification sent", challengeName)
}
+ return nil
}
diff --git a/core/manager/static.go b/core/manager/static.go
index a45c3ef8..3ca20b2a 100644
--- a/core/manager/static.go
+++ b/core/manager/static.go
@@ -3,7 +3,9 @@ package manager
import (
"errors"
"fmt"
+ "os"
"path/filepath"
+ "strings"
"github.com/sdslabs/beastv4/core"
cfg "github.com/sdslabs/beastv4/core/config"
@@ -21,9 +23,8 @@ import (
// The image name for the static content docker image shoule be specified in the
// BEAST_STATIC_CONTAINER_NAME:latest variable
// This function does not build the image for static containers.
-// The port for the deployment of the static container is specified in the variable
-// BEAST_CHALLENGES_STATIC_PORT, this port should be free and will be the port on which
-// nginx container for static files will be running.
+// The static container receives a separate read-only mount for each challenge's public
+// assets. Other staging data is never visible to the web server.
//
// Each challenges have its own static file folder inside the challenges directory.
// The whole staging area of beast configuration is mounted on the docker container
@@ -48,28 +49,26 @@ func DeployStaticContentContainer() error {
return errors.New("IMAGE_NOT_FOUND_ERROR")
}
- // Remove the prefix sha256:
- imageId := images[0].ID[7:]
- stagingDirPath := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_STAGING_DIR)
- err = utils.CreateIfNotExistDir(stagingDirPath)
- if err != nil {
- log.Errorf("Error in validating staging mount point : %s", err)
- return errors.New("INVALID_STAGING_AREA")
- }
+ imageId := strings.TrimPrefix(images[0].ID, "sha256:")
- beastStaticAuthFile := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_STATIC_AUTH_FILE)
- err = utils.ValidateFileExists(beastStaticAuthFile)
+ staticMount := make(map[string]string)
+ challenges, err := database.QueryAllChallenges()
if err != nil {
- p := fmt.Errorf("BEAST STATIC: Authentication file does not exist for beast static container, cannot proceed deployment")
- log.Error(p.Error())
- return p
+ return fmt.Errorf("query static challenge assets: %w", err)
+ }
+ for _, challenge := range challenges {
+ source := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_STAGING_DIR, challenge.Name, core.BEAST_STATIC_FOLDER)
+ info, statErr := os.Lstat(source)
+ if os.IsNotExist(statErr) {
+ continue
+ }
+ if statErr != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
+ return fmt.Errorf("invalid static asset directory for %s", challenge.Name)
+ }
+ staticMount[source] = filepath.Join(core.BEAST_STAGING_AREA_MOUNT_POINT, challenge.Name, core.BEAST_STATIC_FOLDER)
}
-
- staticMount := make(map[string]string)
- staticMount[stagingDirPath] = core.BEAST_STAGING_AREA_MOUNT_POINT
- staticMount[beastStaticAuthFile] = filepath.Join("/", core.BEAST_STATIC_AUTH_FILE)
portMap := cr.PortMapping{
- ContainerPort: core.BEAST_CHALLENGES_STATIC_PORT,
+ ContainerPort: core.BEAST_STATIC_CONTAINER_PORT,
HostPort: core.BEAST_CHALLENGES_STATIC_PORT,
}
@@ -78,6 +77,10 @@ func DeployStaticContentContainer() error {
MountsMap: staticMount,
ImageId: imageId,
ContainerName: core.BEAST_STATIC_CONTAINER_NAME,
+ CPUShares: cfg.Cfg.CPUShares,
+ CPUsLimit: cfg.Cfg.CPUsLimit,
+ Memory: cfg.Cfg.Memory,
+ PidsLimit: cfg.Cfg.PidsLimit,
}
containerId, err := cr.CreateContainerFromImage(&containerConfig)
if err != nil {
@@ -97,13 +100,14 @@ func DeployStaticContentContainer() error {
// This cleans up the container deployed by DeployStaticContentContainer function
// The image is preserved after calling the function and thus need not be build again.
-func UndeployStaticContentContainer() {
+func UndeployStaticContentContainer() error {
err := coreutils.CleanupContainerByFilter("name", core.BEAST_STATIC_CONTAINER_NAME)
if err != nil {
log.Errorf("Error while cleaning old static content container : %s", err)
- } else {
- log.Infof("Static content container undeployed")
+ return err
}
+ log.Infof("Static content container undeployed")
+ return nil
}
// Deploy a static challenge
diff --git a/core/manager/sync.go b/core/manager/sync.go
index 12c7c185..f3a0fc31 100644
--- a/core/manager/sync.go
+++ b/core/manager/sync.go
@@ -68,7 +68,10 @@ func SyncBeastRemote(defaultauthorpassword string) error {
}
log.Info("Beast git base synced with remote")
UpdateChallenges(defaultauthorpassword)
- return fmt.Errorf("%s", strings.Join(errStrings, "\n"))
+ if len(errStrings) != 0 {
+ return fmt.Errorf("%s", strings.Join(errStrings, "\n"))
+ }
+ return nil
}
func ResetBeastRemote(defaultauthorpassword string) error {
@@ -85,12 +88,14 @@ func ResetBeastRemote(defaultauthorpassword string) error {
}
}
}
- err := SyncBeastRemote(defaultauthorpassword)
- if err != nil {
+ if err := SyncBeastRemote(defaultauthorpassword); err != nil {
log.Errorf("Error while syncing remote after clean : %s", err)
+ errStrings = append(errStrings, err.Error())
+ }
+ if len(errStrings) != 0 {
+ return fmt.Errorf("%s", strings.Join(errStrings, "\n"))
}
- errors := strings.Join(errStrings, "\n") + err.Error()
- return fmt.Errorf("%s", errors)
+ return nil
}
// IsAlreadySynced checks if the local repository is already synced
@@ -194,6 +199,8 @@ func SyncAndGetChangesFromRemote(defaultauthorpassword string) []string {
func RunBeastBootsteps(defaultauthorpassword string) error {
log.Info("Syncing beast git challenge dir with remote....")
- _ = SyncBeastRemote(defaultauthorpassword)
+ if err := SyncBeastRemote(defaultauthorpassword); err != nil {
+ return fmt.Errorf("sync Beast remote: %w", err)
+ }
return nil
}
diff --git a/core/manager/sync_test.go b/core/manager/sync_test.go
new file mode 100644
index 00000000..c1b92863
--- /dev/null
+++ b/core/manager/sync_test.go
@@ -0,0 +1,20 @@
+package manager
+
+import (
+ "testing"
+
+ "github.com/sdslabs/beastv4/core/config"
+)
+
+func TestSyncWithoutActiveRemotesSucceeds(t *testing.T) {
+ previous := config.Cfg
+ config.Cfg = &config.BeastConfig{}
+ t.Cleanup(func() { config.Cfg = previous })
+
+ if err := SyncBeastRemote(""); err != nil {
+ t.Fatalf("SyncBeastRemote() = %v, want nil", err)
+ }
+ if err := ResetBeastRemote(""); err != nil {
+ t.Fatalf("ResetBeastRemote() = %v, want nil", err)
+ }
+}
diff --git a/core/manager/utils.go b/core/manager/utils.go
index 497b8a14..199f5dc5 100644
--- a/core/manager/utils.go
+++ b/core/manager/utils.go
@@ -4,15 +4,15 @@ import (
"archive/zip"
"bytes"
"fmt"
- "github.com/sdslabs/beastv4/core/cache"
"io"
"io/ioutil"
"os"
- "path"
"path/filepath"
+ "sort"
"strings"
"text/template"
+ "github.com/sdslabs/beastv4/core/cache"
"github.com/sdslabs/beastv4/pkg/auth"
"github.com/sdslabs/beastv4/pkg/remoteManager"
@@ -23,10 +23,14 @@ import (
tools "github.com/sdslabs/beastv4/templates"
"github.com/sdslabs/beastv4/utils"
- "github.com/BurntSushi/toml"
log "github.com/sirupsen/logrus"
)
+const (
+ maxChallengeArchiveFiles = 4096
+ maxChallengeArchiveBytes = 512 << 20
+)
+
type BeastBareDockerfile struct {
DockerBaseImage string
Ports string
@@ -60,6 +64,23 @@ type ChallengePreview struct {
Points uint
}
+func activeLocalServerName() (string, bool) {
+ if server, exists := cfg.Cfg.AvailableServers[core.LOCALHOST]; exists && server.Active && cfg.Cfg.UseLocalDockerDaemon(core.LOCALHOST) {
+ return core.LOCALHOST, true
+ }
+ names := make([]string, 0, len(cfg.Cfg.AvailableServers))
+ for name := range cfg.Cfg.AvailableServers {
+ names = append(names, name)
+ }
+ sort.Strings(names)
+ for _, name := range names {
+ if server := cfg.Cfg.AvailableServers[name]; server.Active && cfg.Cfg.UseLocalDockerDaemon(name) {
+ return name, true
+ }
+ }
+ return "", false
+}
+
// This if the function which validates the challenge directory
// which is provided as an arguments. This validtions includes
// * A valid directory pointed by challengeDir
@@ -74,8 +95,7 @@ func ValidateChallengeConfig(challengeDir string) error {
return err
}
- var config cfg.BeastChallengeConfig
- _, err = toml.DecodeFile(configFile, &config)
+ config, err := cfg.LoadChallengeConfig(configFile)
if err != nil {
return err
}
@@ -445,32 +465,38 @@ func UpdateOrCreateChallengeDbEntry(challEntry *database.Challenge, config cfg.B
if err != nil {
return fmt.Errorf("error while querying user with email %s", user.Email)
}
+ if u.ID == 0 || u.Status != 0 || (u.Role != core.USER_ROLES["author"] && u.Role != core.USER_ROLES["maintainer"] && u.Role != core.USER_ROLES["admin"]) {
+ return fmt.Errorf("maintainer %s is not an active manager account", user.Email)
+ }
users[i] = &u
}
if userEntry.Email == "" {
- // if defaultauthorpassword == "" {
- // return fmt.Errorf("User with the given email does not exist : %v. You can pass q flag with password to autogenerate authors in this case.", config.Author.Email)
- // }
+ if defaultauthorpassword == "" {
+ return fmt.Errorf("author %s does not exist and no creation password was provided", config.Author.Email)
+ }
log.Infof("User with the given email does not exist : %v, creating this user", config.Author.Email)
+ authModel, err := auth.CreateModel(config.Author.Email, defaultauthorpassword, core.USER_ROLES["author"])
+ if err != nil {
+ return fmt.Errorf("create author credentials: %w", err)
+ }
newUser := database.User{
Name: config.Author.Name,
- AuthModel: auth.CreateModel(config.Author.Email, defaultauthorpassword, core.USER_ROLES["author"]),
+ AuthModel: authModel,
Email: config.Author.Email,
- SshKey: config.Author.SSHKey,
}
err = database.CreateUserEntry(&newUser)
if err != nil {
return err
}
+ userEntry = newUser
log.Infof("Author with the email address %v is created", config.Author.Email)
- // return nil
} else {
- if userEntry.Email != config.Author.Email &&
- (userEntry.SshKey != config.Author.SSHKey || config.Author.SSHKey == "") &&
- (userEntry.Name != config.Author.Name || config.Author.Name == "") &&
- userEntry.Role != core.USER_ROLES["author"] {
- return fmt.Errorf("ERROR, author details for %s did not match with the ones in database", userEntry.Email)
+ if userEntry.Status != 0 || (userEntry.Role != core.USER_ROLES["author"] && userEntry.Role != core.USER_ROLES["admin"]) {
+ return fmt.Errorf("author %s is not an active author account", userEntry.Email)
+ }
+ if config.Author.Name != "" && userEntry.Name != config.Author.Name {
+ return fmt.Errorf("author name for %s does not match the existing account", userEntry.Email)
}
}
@@ -491,8 +517,16 @@ func UpdateOrCreateChallengeDbEntry(challEntry *database.Challenge, config cfg.B
}
availableServerHostname := core.LOCALHOST
if config.Challenge.Metadata.Type != core.STATIC_CHALLENGE_TYPE_NAME {
- availableServer, _ := remoteManager.ServerQueue.GetNextAvailableInstance()
- availableServerHostname = availableServer.Name
+ availableServer, serverErr := remoteManager.ServerQueue.GetNextAvailableInstance()
+ if serverErr == nil {
+ availableServerHostname = availableServer.Name
+ } else {
+ localServerName, exists := activeLocalServerName()
+ if !exists {
+ return fmt.Errorf("no active challenge server is available")
+ }
+ availableServerHostname = localServerName
+ }
}
if config.Challenge.Metadata.Difficulty == "" {
log.Debug("Setting difficulty to default(medium)")
@@ -547,9 +581,9 @@ func UpdateOrCreateChallengeDbEntry(challEntry *database.Challenge, config cfg.B
}
}
- database.Db.Model(challEntry).Association("Tags").Append(tags)
-
- database.Db.Model(challEntry).Association("Users").Append(users)
+ if err := database.SetChallengeRelations(challEntry, tags, users); err != nil {
+ return fmt.Errorf("set challenge relations: %w", err)
+ }
}
allocatedPorts, err := database.GetAllocatedPorts(*challEntry)
@@ -593,6 +627,7 @@ func UpdateOrCreateChallengeDbEntry(challEntry *database.Challenge, config cfg.B
portEntry := database.Port{
ChallengeID: challEntry.ID,
+ Server: challEntry.ServerDeployed,
PortNo: port,
}
@@ -621,8 +656,7 @@ func UpdateOrCreateChallengeDbEntry(challEntry *database.Challenge, config cfg.B
// Provides the Static Content Folder Name from the config
func GetStaticContentDir(configFile, contextDir string) (string, error) {
- var config cfg.BeastChallengeConfig
- _, err := toml.DecodeFile(configFile, &config)
+ config, err := cfg.LoadChallengeConfig(configFile)
if err != nil {
return "", fmt.Errorf("error while decoding file : %s", configFile)
}
@@ -642,13 +676,9 @@ func LogTransaction(identifier string, action string, authorization string) erro
return fmt.Errorf("error while querying challenge: %s", identifier)
}
- // We are trying to get the username for the request from JWT claims here
- // Since upto this point the request is already authorized, we use a default
- // username if any error occurs while getting the username.
userName, err := coreUtils.GetUser(authorization)
if err != nil {
- log.Warnf("Error while getting user from authorization header, using default user(since already authorized)")
- userName = core.DEFAULT_USER_NAME
+ return fmt.Errorf("resolve transaction user: %w", err)
}
user, err := database.QueryFirstUserEntry("username", userName)
@@ -670,19 +700,36 @@ func LogTransaction(identifier string, action string, authorization string) erro
// Copies the Static content to the staging/static/folder
func CopyToStaticContent(challengeName, staticContentDir string) error {
dirPath := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_STAGING_DIR, challengeName, core.BEAST_STATIC_FOLDER)
- err := utils.CreateIfNotExistDir(dirPath)
- if err != nil {
- return fmt.Errorf("error while copying static content : %v", err)
+ if _, err := os.Lstat(dirPath); err == nil {
+ if err := utils.RemoveDirRecursively(dirPath); err != nil {
+ return fmt.Errorf("clear static content: %w", err)
+ }
+ } else if !os.IsNotExist(err) {
+ return fmt.Errorf("inspect static content destination: %w", err)
}
- err = utils.ValidateDirExists(staticContentDir)
+ err := utils.ValidateDirExists(staticContentDir)
if err != nil {
log.Warnf("%s : There is no static directory inside challenge, skipping copy.", challengeName)
return nil
}
- err = utils.CopyDirectory(staticContentDir, dirPath)
- return err
+ if err := utils.CopyDirectory(staticContentDir, dirPath); err != nil {
+ return err
+ }
+ return filepath.WalkDir(dirPath, func(path string, entry os.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if entry.Type()&os.ModeSymlink != 0 {
+ return fmt.Errorf("static asset is a symbolic link: %s", path)
+ }
+ mode := os.FileMode(0644)
+ if entry.IsDir() {
+ mode = 0755
+ }
+ return os.Chmod(path, mode)
+ })
}
func GetAvailableChallenges() ([]string, error) {
@@ -723,131 +770,182 @@ func ExtractChallengeNamesFromFileNames(fileNames []string) []string {
// Unzips challenge folder in a destination directory
func UnzipChallengeFolder(zipContextPath, dstPath string) (string, error) {
-
baseFileName := filepath.Base(zipContextPath)
targetDir := filepath.Join(dstPath, strings.TrimSuffix(baseFileName, filepath.Ext(baseFileName)))
- if err := os.MkdirAll(targetDir, os.ModePerm); err != nil {
- log.Fatal(err)
- }
-
- // 1. Open the zip file
reader, err := zip.OpenReader(zipContextPath)
if err != nil {
return "", err
}
defer reader.Close()
- // 2. Get the absolute destination path
destination, err := filepath.Abs(targetDir)
if err != nil {
return "", err
}
+ if _, err := os.Lstat(destination); err == nil {
+ return "", fmt.Errorf("archive destination already exists: %s", destination)
+ } else if !os.IsNotExist(err) {
+ return "", err
+ }
+ if err := os.MkdirAll(destination, 0700); err != nil {
+ return "", err
+ }
+ complete := false
+ defer func() {
+ if !complete {
+ _ = os.RemoveAll(destination)
+ }
+ }()
+
+ if len(reader.File) > maxChallengeArchiveFiles {
+ return "", fmt.Errorf("archive contains too many entries: %d (maximum %d)", len(reader.File), maxChallengeArchiveFiles)
+ }
- // 3. Iterate over zip files inside the archive and unzip each of them
+ var totalSize uint64
+ seen := make(map[string]struct{}, len(reader.File))
for _, f := range reader.File {
- err := unzipFile(f, destination)
+ if f.UncompressedSize64 > maxChallengeArchiveBytes-totalSize {
+ return "", fmt.Errorf("archive expands beyond the %d-byte limit", maxChallengeArchiveBytes)
+ }
+ totalSize += f.UncompressedSize64
+
+ relPath, err := safeArchivePath(f.Name)
if err != nil {
return "", err
}
+ if _, exists := seen[relPath]; exists {
+ return "", fmt.Errorf("archive contains duplicate path %q", f.Name)
+ }
+ seen[relPath] = struct{}{}
+
+ if err := unzipFile(f, destination, relPath); err != nil {
+ return "", err
+ }
}
+ complete = true
return targetDir, nil
}
-func unzipFile(f *zip.File, destination string) error {
- // 4. Check if file paths are not vulnerable to [Zip Slip](https://snyk.io/research/zip-slip-vulnerability)
- filePath := filepath.Join(destination, f.Name)
- if !strings.HasPrefix(filePath, filepath.Clean(destination)+string(os.PathSeparator)) {
- return fmt.Errorf("invalid file path: %s", filePath)
+func safeArchivePath(name string) (string, error) {
+ if name == "" || strings.ContainsRune(name, '\x00') || strings.Contains(name, `\`) {
+ return "", fmt.Errorf("archive contains invalid path %q", name)
+ }
+ relPath := filepath.Clean(filepath.FromSlash(name))
+ if relPath == "." || filepath.IsAbs(relPath) || relPath == ".." || strings.HasPrefix(relPath, ".."+string(os.PathSeparator)) {
+ return "", fmt.Errorf("archive path escapes destination: %q", name)
}
+ return relPath, nil
+}
+
+func unzipFile(f *zip.File, destination, relPath string) error {
+ filePath := filepath.Join(destination, relPath)
- // 5. Create directory tree
if f.FileInfo().IsDir() {
- if err := os.MkdirAll(filePath, os.ModePerm); err != nil {
- return err
- }
- return nil
+ return os.MkdirAll(filePath, 0755)
+ }
+ if !f.Mode().IsRegular() {
+ return fmt.Errorf("archive contains unsupported entry %q with mode %s", f.Name, f.Mode())
}
- if err := os.MkdirAll(filepath.Dir(filePath), os.ModePerm); err != nil {
+ if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil {
return err
}
- // 6. Create a destination file for unzipped content
- destinationFile, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
+ mode := f.Mode().Perm() & 0777
+ destinationFile, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
if err != nil {
return err
}
- defer destinationFile.Close()
- // 7. Unzip the content of a file and copy it to the destination file
zippedFile, err := f.Open()
if err != nil {
+ _ = destinationFile.Close()
return err
}
- defer zippedFile.Close()
- if _, err := io.Copy(destinationFile, zippedFile); err != nil {
+ written, copyErr := io.Copy(destinationFile, io.LimitReader(zippedFile, int64(f.UncompressedSize64)+1))
+ closeSourceErr := zippedFile.Close()
+ closeDestinationErr := destinationFile.Close()
+ if copyErr != nil {
+ return copyErr
+ }
+ if closeSourceErr != nil {
+ return closeSourceErr
+ }
+ if closeDestinationErr != nil {
+ return closeDestinationErr
+ }
+ if written != int64(f.UncompressedSize64) {
+ _ = os.Remove(filePath)
+ return fmt.Errorf("archive entry %q size does not match its header", f.Name)
+ }
+ if err := os.Chmod(filePath, mode); err != nil {
return err
}
return nil
}
-// File copies a single file from src to dst
+// CopyFile copies one regular file without following links or replacing a path.
func CopyFile(src, dst string) error {
- var err error
- var srcfd *os.File
- var dstfd *os.File
- var srcinfo os.FileInfo
-
- if srcfd, err = os.Open(src); err != nil {
+ srcInfo, err := os.Lstat(src)
+ if err != nil {
return err
}
- defer srcfd.Close()
+ if !srcInfo.Mode().IsRegular() {
+ return fmt.Errorf("source is not a regular file: %s", src)
+ }
- if dstfd, err = os.Create(dst); err != nil {
+ srcFile, err := os.Open(src)
+ if err != nil {
return err
}
- defer dstfd.Close()
+ defer srcFile.Close()
- if _, err = io.Copy(dstfd, srcfd); err != nil {
+ dstFile, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, srcInfo.Mode().Perm())
+ if err != nil {
+ return err
+ }
+ if _, err := io.Copy(dstFile, srcFile); err != nil {
+ _ = dstFile.Close()
+ _ = os.Remove(dst)
return err
}
- if srcinfo, err = os.Stat(src); err != nil {
+ if err := dstFile.Close(); err != nil {
+ _ = os.Remove(dst)
return err
}
- return os.Chmod(dst, srcinfo.Mode())
+ return nil
}
-// Dir copies a whole directory recursively
-func CopyDir(src string, dst string) error {
- var err error
- var fds []os.FileInfo
- var srcinfo os.FileInfo
-
- if srcinfo, err = os.Stat(src); err != nil {
+// CopyDir copies a directory tree without following links or replacing paths.
+func CopyDir(src, dst string) error {
+ srcInfo, err := os.Lstat(src)
+ if err != nil {
return err
}
+ if !srcInfo.IsDir() || srcInfo.Mode()&os.ModeSymlink != 0 {
+ return fmt.Errorf("source is not a directory: %s", src)
+ }
- if err = os.MkdirAll(dst, srcinfo.Mode()); err != nil {
+ if err := os.Mkdir(dst, srcInfo.Mode().Perm()); err != nil {
return err
}
-
- if fds, err = ioutil.ReadDir(src); err != nil {
+ entries, err := os.ReadDir(src)
+ if err != nil {
return err
}
- for _, fd := range fds {
- srcfp := path.Join(src, fd.Name())
- dstfp := path.Join(dst, fd.Name())
-
- if fd.IsDir() {
- if err = CopyDir(srcfp, dstfp); err != nil {
- fmt.Println(err)
- }
- } else {
- if err = CopyFile(srcfp, dstfp); err != nil {
- fmt.Println(err)
+ for _, entry := range entries {
+ srcPath := filepath.Join(src, entry.Name())
+ dstPath := filepath.Join(dst, entry.Name())
+ if entry.IsDir() {
+ if err := CopyDir(srcPath, dstPath); err != nil {
+ return err
}
+ continue
+ }
+ if err := CopyFile(srcPath, dstPath); err != nil {
+ return err
}
}
return nil
@@ -874,8 +972,7 @@ func UpdateChallenges(defaultauthorpassword string) {
for _, dir := range dirs {
configFile := filepath.Join(dir, core.CHALLENGE_CONFIG_FILE_NAME)
- var config cfg.BeastChallengeConfig
- _, err := toml.DecodeFile(configFile, &config)
+ config, err := cfg.LoadChallengeConfig(configFile)
if err != nil {
log.Errorf("Error while decoding challenge config file for challenge dir %s: %s", dir, err.Error())
continue
@@ -898,7 +995,7 @@ func UpdateChallenges(defaultauthorpassword string) {
continue
}
- challenge, err := database.QueryFirstChallengeEntry("name", config.Challenge.Metadata.Name)
+ challenge, _, err := database.FindFirstChallengeEntry("name", config.Challenge.Metadata.Name)
if err != nil {
log.Errorf("Error while querying challenge %s : %s", config.Challenge.Metadata.Name, err)
continue
diff --git a/core/utils/cleanup.go b/core/utils/cleanup.go
index b30afd3f..dc16c7d8 100644
--- a/core/utils/cleanup.go
+++ b/core/utils/cleanup.go
@@ -70,9 +70,7 @@ func CleanupChallengeContainers(chall *database.Challenge, config cfg.BeastChall
if !cfg.Cfg.UseLocalDockerDaemon(chall.ServerDeployed) {
server := cfg.Cfg.AvailableServers[chall.ServerDeployed]
- downCommand := fmt.Sprintf("docker compose -p %s down", projectName)
- _, err := remoteManager.RunCommandOnServer(server, downCommand)
- if err != nil {
+ if err := remoteManager.ComposeDownProjectRemote(projectName, server); err != nil {
log.Errorf("Error running docker compose down on remote: %v", err)
return err
}
@@ -126,13 +124,13 @@ func CleanupChallengeImage(chall *database.Challenge) error {
}
func CleanupChallengeIfExist(config cfg.BeastChallengeConfig) error {
- chall, err := database.QueryFirstChallengeEntry("name", config.Challenge.Metadata.Name)
+ chall, found, err := database.FindFirstChallengeEntry("name", config.Challenge.Metadata.Name)
if err != nil {
log.Errorf("Error while database query for challenge %s", config.Challenge.Metadata.Name)
return err
}
- if chall.Name == "" {
+ if !found {
log.Info("No such challenge exist in the database")
return nil
}
diff --git a/core/utils/command_utils.go b/core/utils/command_utils.go
index 3327d800..d16eb8a9 100644
--- a/core/utils/command_utils.go
+++ b/core/utils/command_utils.go
@@ -2,52 +2,35 @@ package utils
import (
"fmt"
- "os"
"github.com/sdslabs/beastv4/core"
"github.com/sdslabs/beastv4/core/database"
"github.com/sdslabs/beastv4/pkg/auth"
- "github.com/sdslabs/beastv4/utils"
- log "github.com/sirupsen/logrus"
)
-func CreateAdminOrAuthor(name string, username string, email string, publicKeyPath string, password string, role string) {
- var sshKey []byte
- if publicKeyPath != "" {
- err := utils.ValidateFileExists(publicKeyPath)
- if err != nil {
- log.Errorf("Error while checking validity of file(%v): %v : ", publicKeyPath, err)
- return
- }
-
- sshKey, err = os.ReadFile(publicKeyPath)
- if err != nil {
- log.Errorf("Error while reading file: %v", err)
- return
- }
-
- } else {
- log.Warn("SSH Key for author is not provided")
+func CreateAdminOrAuthor(name string, username string, email string, password string, role string) error {
+ authModel, err := auth.CreateModel(username, password, core.USER_ROLES[role])
+ if err != nil {
+ return err
}
-
userEntry := database.User{
Name: name,
- AuthModel: auth.CreateModel(username, password, core.USER_ROLES[role]),
+ AuthModel: authModel,
Email: email,
- SshKey: string(sshKey),
}
- err := database.CreateUserEntry(&userEntry)
+ err = database.CreateUserEntry(&userEntry)
if err != nil {
- log.Errorf("Error while creating author entry : %v", err)
+ return fmt.Errorf("create author entry: %w", err)
}
+ return nil
}
func DeleteChallengeEntryWithPorts(challname string) error {
- chall, err := database.QueryFirstChallengeEntry("name", challname)
+ chall, found, err := database.FindFirstChallengeEntry("name", challname)
if err != nil {
return fmt.Errorf("Error while querying database : %v", err)
}
- if chall.Name == "" {
+ if !found {
return nil
}
ports, err := database.GetAllocatedPorts(chall)
diff --git a/core/utils/dateparser.go b/core/utils/dateparser.go
index 9fa37d41..b8adb8ae 100644
--- a/core/utils/dateparser.go
+++ b/core/utils/dateparser.go
@@ -1,13 +1,9 @@
package utils
import (
- "fmt"
- "strings"
"time"
"github.com/sdslabs/beastv4/core/config"
-
- "github.com/araddon/dateparse"
)
func CheckTime() (error, int) {
@@ -17,31 +13,11 @@ func CheckTime() (error, int) {
return err, -1
}
- loc, _ := time.LoadLocation(strings.Split(competitionInfo.TimeZone, ":")[0])
- time.Local = loc
- currentTime := time.Now().In(loc)
-
- compStartTime := strings.Split(competitionInfo.StartingTime, ",")
- compStartDate := strings.Split(compStartTime[1][1:], " ")
- startDate := fmt.Sprintf("%s %s, %s", compStartDate[1], compStartDate[0], compStartDate[2])
- startTime := strings.Split(compStartTime[0], " ")[0]
- startTime = fmt.Sprintf("%s, %s", startDate, startTime)
-
- st, err := dateparse.ParseLocal(startTime)
- if err != nil {
- return err, -1
- }
-
- compEndTime := strings.Split(competitionInfo.EndingTime, ",")
- compEndDate := strings.Split(compEndTime[1][1:], " ")
- endDate := fmt.Sprintf("%s %s, %s", compEndDate[1], compEndDate[0], compEndDate[2])
- endTime := strings.Split(compEndTime[0], " ")[0]
- endTime = fmt.Sprintf("%s, %s", endDate, endTime)
-
- et, err := dateparse.ParseLocal(endTime)
+ st, et, err := competitionInfo.ParseWindow()
if err != nil {
return err, -1
}
+ currentTime := time.Now().In(st.Location())
if currentTime.Before(st) {
return nil, 0
diff --git a/core/utils/dateparser_test.go b/core/utils/dateparser_test.go
new file mode 100644
index 00000000..ee922b9c
--- /dev/null
+++ b/core/utils/dateparser_test.go
@@ -0,0 +1,21 @@
+package utils
+
+import (
+ "testing"
+
+ "github.com/sdslabs/beastv4/core/config"
+)
+
+func TestCheckTimeRejectsMalformedWindowWithoutPanicking(t *testing.T) {
+ previous := config.Cfg
+ config.Cfg = &config.BeastConfig{CompetitionInfo: config.CompetitionInfo{
+ StartingTime: "malformed",
+ EndingTime: "also malformed",
+ TimeZone: "UTC",
+ }}
+ t.Cleanup(func() { config.Cfg = previous })
+
+ if err, state := CheckTime(); err == nil || state != -1 {
+ t.Fatalf("expected malformed time error and state -1, got %v and %d", err, state)
+ }
+}
diff --git a/core/utils/file.go b/core/utils/file.go
index 06eaff08..07b5f7b0 100644
--- a/core/utils/file.go
+++ b/core/utils/file.go
@@ -9,24 +9,24 @@ import (
)
func GetChallengeDir(challengeName string) string {
- challengeRemoteDir := ""
+ if config.Cfg == nil || !config.IsValidChallengeName(challengeName) {
+ return ""
+ }
for _, gitRemote := range config.Cfg.GitRemotes {
- if gitRemote.Active == true {
- challengeRemoteDir = filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_REMOTES_DIR,
+ if gitRemote.Active {
+ challengeRemoteDir := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_REMOTES_DIR,
gitRemote.RemoteName, core.BEAST_REMOTE_CHALLENGE_DIR, challengeName)
- err := utils.ValidateDirExists(challengeRemoteDir)
- if err == nil {
+ if err := utils.ValidateDirExists(challengeRemoteDir); err == nil {
return challengeRemoteDir
}
}
}
- challengeRemoteDir = filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_UPLOADS_DIR, challengeName)
- err := utils.ValidateDirExists(challengeRemoteDir)
- if err == nil {
- return challengeRemoteDir
+ challengeUploadDir := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_UPLOADS_DIR, challengeName)
+ if err := utils.ValidateDirExists(challengeUploadDir); err == nil {
+ return challengeUploadDir
}
- return challengeRemoteDir
+ return ""
}
diff --git a/core/utils/file_test.go b/core/utils/file_test.go
new file mode 100644
index 00000000..2dd50b92
--- /dev/null
+++ b/core/utils/file_test.go
@@ -0,0 +1,35 @@
+package utils
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/sdslabs/beastv4/core"
+ "github.com/sdslabs/beastv4/core/config"
+)
+
+func TestGetChallengeDirFailsClosed(t *testing.T) {
+ previousRoot := core.BEAST_GLOBAL_DIR
+ previousConfig := config.Cfg
+ core.BEAST_GLOBAL_DIR = t.TempDir()
+ config.Cfg = &config.BeastConfig{}
+ defer func() {
+ core.BEAST_GLOBAL_DIR = previousRoot
+ config.Cfg = previousConfig
+ }()
+
+ for _, name := range []string{"", "missing", "../escape", "/absolute"} {
+ if path := GetChallengeDir(name); path != "" {
+ t.Fatalf("GetChallengeDir(%q) = %q, want empty", name, path)
+ }
+ }
+
+ want := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_UPLOADS_DIR, "valid-challenge")
+ if err := os.MkdirAll(want, 0700); err != nil {
+ t.Fatal(err)
+ }
+ if got := GetChallengeDir("valid-challenge"); got != want {
+ t.Fatalf("GetChallengeDir() = %q, want %q", got, want)
+ }
+}
diff --git a/core/utils/general.go b/core/utils/general.go
index 80789567..47e2cf9d 100644
--- a/core/utils/general.go
+++ b/core/utils/general.go
@@ -1,8 +1,6 @@
package utils
import (
- b64 "encoding/base64"
- "encoding/json"
"fmt"
"strings"
@@ -15,24 +13,13 @@ func GetUser(authHeader string) (string, error) {
}
values := strings.Split(authHeader, " ")
- if len(values) < 2 {
+ if len(values) != 2 || values[0] != "Bearer" {
return "", fmt.Errorf("Not a valid authorization header")
}
- jwtToken := values[1]
- userInfoEncr := strings.Split(jwtToken, ".")
- if len(userInfoEncr) != 3 {
- return "", fmt.Errorf("Not a valid JWT token in authorization header: %s", jwtToken)
- }
-
- sDec, err := b64.StdEncoding.WithPadding(b64.NoPadding).DecodeString(userInfoEncr[1])
+ claims, err := auth.AuthorizeClaims(values[1], auth.MANAGER|auth.ADMIN|auth.USER)
if err != nil {
- return "", fmt.Errorf("Error in decrypting JWT token: %s", err)
+ return "", fmt.Errorf("invalid authorization token: %w", err)
}
-
- in := []byte(sDec)
- var raw auth.CustomClaims
- json.Unmarshal(in, &raw)
-
- return raw.User, nil
+ return claims.User, nil
}
diff --git a/core/utils/logs.go b/core/utils/logs.go
index 956358e7..90a701a5 100644
--- a/core/utils/logs.go
+++ b/core/utils/logs.go
@@ -27,19 +27,21 @@ func GetLogs(challname string, live bool) (*cr.Log, error) {
if !IsContainerIdValid(chall.ContainerId) {
return nil, fmt.Errorf("Underlying challenge configuration present is not valid.")
}
- var containers, remoteContainers []container_types.Container
- server := config.AvailableServer{}
- remoteContainers, err = remoteManager.SearchContainerByFilterRemote(map[string]string{"id": chall.ContainerId}, server)
- if err != nil {
- return nil, fmt.Errorf("Error while searching for remote container with id %s", chall.ContainerId)
+ var containers []container_types.Container
+ if config.Cfg.UseLocalDockerDaemon(chall.ServerDeployed) {
+ containers, err = cr.SearchContainerByFilter(map[string]string{"id": chall.ContainerId})
+ } else {
+ server, ok := config.Cfg.AvailableServers[chall.ServerDeployed]
+ if !ok {
+ return nil, fmt.Errorf("deployment server %q is not configured", chall.ServerDeployed)
+ }
+ containers, err = remoteManager.SearchContainerByFilterRemote(map[string]string{"id": chall.ContainerId}, server)
}
- containers, err = cr.SearchContainerByFilter(map[string]string{"id": chall.ContainerId})
if err != nil {
- return nil, fmt.Errorf("Error while searching for container with id %s", chall.ContainerId)
+ return nil, fmt.Errorf("search for container %s: %w", chall.ContainerId, err)
}
- containers = append(containers, remoteContainers...)
if len(containers) > 1 {
- return nil, errors.New("Got more than one containers, something fishy here. Contact admin to check manually.")
+ return nil, errors.New("multiple containers matched the challenge container ID")
}
if len(containers) == 0 {
@@ -48,10 +50,14 @@ func GetLogs(challname string, live bool) (*cr.Log, error) {
if live {
if config.Cfg.UseLocalDockerDaemon(chall.ServerDeployed) {
- cr.ShowLiveContainerLogs(chall.ContainerId)
+ if err := cr.ShowLiveContainerLogs(chall.ContainerId); err != nil {
+ return nil, err
+ }
} else {
server := config.Cfg.AvailableServers[chall.ServerDeployed]
- remoteManager.ShowLiveContainerLogsRemote(chall.ContainerId, server)
+ if err := remoteManager.ShowLiveContainerLogsRemote(chall.ContainerId, server); err != nil {
+ return nil, err
+ }
}
return nil, nil
}
@@ -60,7 +66,7 @@ func GetLogs(challname string, live bool) (*cr.Log, error) {
return cr.GetContainerStdLogs(chall.ContainerId)
}
- server = config.Cfg.AvailableServers[chall.ServerDeployed]
+ server := config.Cfg.AvailableServers[chall.ServerDeployed]
return remoteManager.GetContainerStdLogsRemote(chall.ContainerId, server)
}
diff --git a/docs/APIAuth.md b/docs/APIAuth.md
index a2a2fb1b..89b134a6 100644
--- a/docs/APIAuth.md
+++ b/docs/APIAuth.md
@@ -1,62 +1,49 @@
# Authentication
-Authentication is done to provide restricted access only to the organizers. Authentication is done using asymmetric encryption. After authentication, JWT tokens are used to provide access for further usage of API's.
+Beast uses password authentication and HMAC-signed JWT bearer tokens. There is no SSH-key challenge-response login and authorization cannot be disabled from the command line.
-## Usage
+## Credentials
-### Configuration
+- Passwords must contain 12–128 bytes and cannot be whitespace-only.
+- Contestant usernames are 3–12 lowercase letters, digits, dots, underscores, or hyphens.
+- `jwt_secret` must contain at least 32 bytes and must be unique per deployment.
+- Tokens expire after six hours. Password-reset tokens have a separate, restricted claim type.
-For signing JWT tokens using **HMAC** algorithm `jwt_secret` is required which can be configured in the beast global config (config.toml) as:
+Create the first administrator during `beast init`. Additional authors/admins can be created with the CLI on the controller host.
-```toml
-jwt_secret = "beast_jwt_secret_SUPER_STRONG_0x100010000100"
-```
-
-The SSH keys are used for assymetric authentication which can be registered directly from the terminal using:
-
-`beast create-author --name --email --publickey `
-
-Or it can be given in the challenge description(beast.toml) :
+## Login
-```toml
-[author]
-ssh_key = ""
+```bash
+curl --cacert "$HOME/.beast/secrets/tls.crt" \
+ --request POST https://localhost:5005/auth/login \
+ --data-urlencode 'username=admin' \
+ --data-urlencode 'password='
```
-This key gets added in the database when beast.toml is validated.
+The response contains `token`, `role`, and `message`. Supply the token exactly as:
-## Flow
+```text
+Authorization: Bearer
+```
-Once the public key is registered in the database, the user can get a JWT token through the following steps :
-* First make a GET request on URL : `/auth/`
-* The response will be of the format :
+The CLI performs the same flow and verifies TLS:
-``` JSON
-{
- "challenge" : "Challenge String",
- "message" : ""
-}
+```bash
+beast getauth --host https://localhost:5005 \
+ --ca-file "$HOME/.beast/secrets/tls.crt" \
+ --username admin
```
-* The challenge must be decrypted using *ssh private key* and then a POST request has to be made on URL: `/auth/`along with POST form data: `decrmess=`
-* You will get a response like this :
-``` JSON
-{
- "token" : "YOUR_AUTHENTICATION_TOKEN",
- "role" : "",
- "message" : ""
-}
-```
+Login attempts are rate-limited, unknown users receive the same public failure as bad passwords, and banned users cannot obtain tokens. Keep the API behind network access controls as rate limiting is not a substitute for perimeter protection.
-* Now to access any restricted route you need to add this JWT token in the HTTP header as :
-``` HTTP
-Authorization: Bearer YOUR_AUTHENTICATION_TOKEN
-```
+## Roles
-### Alternative
+- `contestant`: competition and instance APIs.
+- `author` / `maintainer`: challenge-management routes only for challenges they own or maintain; uploaded configuration email ownership is checked.
+- `admin`: bulk/scheduled operations, controller-local path deployment, remote synchronization, configuration mutation, static-service management, user control, and administrative instance operations.
-If you have the `beast` binary with you, you can also use command :
+Authorization is checked both at route level and against challenge ownership for sensitive operations such as logs and container execution.
-`beast getauth --identity --username --host `
+## Browser access
-This command will give you the JWT token for usage in other APIs by adding in the HTTP header.
\ No newline at end of file
+CORS is disabled unless `server.allowed_origins` is populated. Origins are exact and credentialed cross-origin requests are disabled. Never use a wildcard origin for an administrative frontend.
diff --git a/docs/Architecture.md b/docs/Architecture.md
index 88051abe..4fa6194c 100644
--- a/docs/Architecture.md
+++ b/docs/Architecture.md
@@ -1,51 +1,40 @@
# Architecture
-This section deals with how the different pieces of beast fall together to create a robust and flexible
-deployment pipeline. For internals of each section itself go to respective documentation pages.
+## Controller
-Important components of beast are described below:
+The Beast process owns the HTTPS API, scheduler, bounded worker queue, health/instance reconcilers, PostgreSQL pool, Redis client, and remote-worker registry. A filesystem lock prevents two controllers from using the same local state directory. Shutdown drains controller-owned goroutines and connections but intentionally leaves deployed challenges running.
-### API Server
+## Durable and coordination state
-API server is the interface provided by beast to interact with the underlying application. All the actions performed by beast
-are propogated by an external agent from this point. The API server is a HTTP REST API service built on top of go-gin framework.
-For authentication purposes it uses JWT which can be optionally turned off when running the server.
+PostgreSQL is the durable source for users, challenge metadata, ownership, submissions, scores, runtime identifiers, and deployed ports. Critical submission and dynamic-flag transitions use transactions, row locks, and unique indexes. Ports are unique per deployment server.
-Requests for beast API are split across five namespaces
+Redis stores namespaced coordination data, port allocation, cached metadata, and expiring instance markers. Atomic Lua/pipeline operations protect shared updates. Expiry notifications accelerate cleanup, while periodic reconciliation is the fallback.
-* **manage**: Challenge management related APIs. Ex. Deploy, Undeploy etc.
-* **info**: Challenge information related APIs. Ex. ChallengeInfo etc.
-* **status**: Challenge status related APIs. Ex. Challenge status
-* **remote**: Beast remote repository related APIs. Ex. SyncRemote
-* **config**: Beast global configuration related APIs. Ex. Reload Config
-* **notification**: Competition notification related APIs. Ex. Add Notifications
-* **admin**: Competition related APIs requiring admin prevalages. Ex. Competition Stastics
+## Deployment workers
-### Manager
+Challenges run on the local Docker daemon or active SSH workers. Worker selection is round-robin. Remote host keys are verified from `known_hosts`; unavailable active workers fail startup rather than silently reducing capacity.
-This part is the brains of beast and does all the action corresponding to lifecycle management of challenges along with managing configuration
-and authentication. The part lives inside `core/` directory and uses helpers/interfaces from inside of `pkg/` and `/utils`.
+Challenge builds apply CPU, memory, and PID limits. Generated service images run challenge commands as an unprivileged user unless an explicit entrypoint/custom image requires otherwise. Docker/Compose authors remain trusted because build instructions execute with Docker authority.
-### Container Runtime
+## Request and task flow
-Container runtime is a wrapper around docker client library which provides beast with helper function to deal with underlying container runtime.
+1. TLS and request-size limits are applied.
+2. Authentication establishes JWT claims and role middleware.
+3. Ownership checks authorize challenge-specific management operations.
+4. Strict configuration/path validation runs before staging.
+5. Work enters a bounded, de-duplicating queue.
+6. The selected local/remote runtime performs build or lifecycle work.
+7. Completion and failures are recorded and returned/logged; database state changes are transactional where multiple relations must move together.
-Currently this only supports `docker` as an underlying container runtime but we will soon be creating a generalized interface which can be fulfilled
-by most of the container runtimes. Something similar on the lines of CRI(Container Runtime Interface for kubernetes).
+Scheduled tasks never overlap with themselves. Health probes use timeouts, verify HTTPS certificates, reject redirects, and bound response bodies.
-### Queue and Workers
+## Static service
-All the tasks performed by beast are non synchronous and are handled by a queue. Whenever a new action is performed corresponding to beast API
-a new task is created using the action configuration and pushed to an internal queue. The result is returned immediately by the API server notifying about
-the start of the process.
+Static challenge content is copied to normalized public directories. A digest-pinned `nginx-unprivileged` image runs as UID 101 and receives only per-challenge read-only mounts, never the whole staging tree. The service listens on container port 8080 and is published on host port 8034; production deployments should terminate public TLS in front of it.
-Beast workers are the actual underlying goroutines which handles the tasks assigned by the Queue. They perform the required task and then take on
-the next. Consider them as the threadpool for beast.
+## Trust boundaries
-## Challenge Flow
-
-
-
-## Deployment Pipeline
-
-
+- API users are untrusted; inputs, body sizes, filenames, archive entries, and authorization are validated.
+- Challenge authors are trusted to supply build code, but paths and dangerous Compose runtime controls are constrained.
+- Docker and remote SSH credentials are highly privileged and must be protected as root-equivalent.
+- PostgreSQL, Redis, Git, SMTP, and webhook networks are external trust boundaries and require authenticated, verified transport when remote.
diff --git a/docs/ChallConfig.md b/docs/ChallConfig.md
index cc2bea22..10cd0fde 100644
--- a/docs/ChallConfig.md
+++ b/docs/ChallConfig.md
@@ -1,172 +1,119 @@
-# Challenge Config
+# Challenge configuration
-You can think of beast as a wrapper around the underlying container runtime with a lot of addtional functionalities
-including lifecycle management, health checks etc. On a very high level you can say that **beast is to
-CTF challenges what Docker is to container.**
+Every challenge directory contains a strict TOML file named `beast.toml`. Unknown keys are rejected. Generate a parseable static scaffold with `beast new`, then validate changes with:
-Now that we know what is beast actually is(just a wrapper around challenge containers) the question is Why Beast?
-The answer to which lies in Why docker when you have runc?
-
-Similar to what docker provides, a high level abstraction to manage the lifecycle, network, state etc among other things
-for containers, beast provides a nice abstraction to create, manage and deploy challenges. This allows a challenge creator to
-focus on creating the challenge rather than thinking of everything else. It exposes a very little overhead to the
-side of Challenge Creator apart from creating a challenge and handles everything itself.
-
-Challenge configuration is the heart of challenge deployment using beast. You can think of it as the blueprint for the
-challenge which requires some metadata holding instructions to beast on how to handle the challenge. Think of it as what Dockerfile is
-to Docker image.
-
-Internally since everything we do in beast revolves around containers, this configuration is also used to generate a _Dockerfile_ which is
-then used to build the images for the underlying atomic elements to a challenge a container. Think of this challenge coniguration as
-a nice wrapper around the Dockerfile itself which is more understandable from a Security Researcher perspective than all the Jargon
-in Dockerfile.
-
-## Structure
+```bash
+beast verify --local-directory /absolute/path/to/challenge
+```
-The configuration corresponding to a challenge is writtern to a file named `beast.toml` in the root of the challenge directory.
-The configuration itself is very minimilistic and is provided in TOML format(mostly because of it's highly readable syntax).
+Challenge names must match `^[a-z0-9][a-z0-9._-]{0,63}$`. Referenced files/directories must be relative, regular entries that resolve inside the challenge root; symlinks and path traversal are rejected during validation/staging.
-There are three main sections to the configuration the structure of which is as below.
+## Author and maintainers
```toml
-# Section containing the details corresponding the the author of challenge
[author]
+name = "Author Name"
+email = "author@example.com"
-# Stores details corresponding to metadata of challenge
-[challenge.metadata]
-
-# Contains the environment or deployment details of the challenge
-[challenge.env]
+[[maintainer]]
+name = "Maintainer Name"
+email = "maintainer@example.com"
```
-All the keys accepted by these sections are mentioned below:
-
-### Author
-
-This section contains the metadata about the author, it is used for various purposes among which the most important
-one is giving the challenge environment access to Author for testing and debugging purposes.
+Email is required and must be canonical. Existing users identified by email become challenge managers; ownership is enforced by management, logs, and execution endpoints.
-This section accepts the following fields
+## Metadata
```toml
-# Optional fields
-name = ""
-
-# Required Fields
-email = ""
-ssh_key = "" # Public ssh Key of the author.
+[challenge.metadata]
+name = "example-web"
+type = "web:php"
+flag = "flag{replace-me}"
+dynamicFlag = false
+difficulty = "medium"
+description = "Example challenge"
+tags = ["web", "php"]
+points = 500
+minPoints = 100
+maxPoints = 500
+maxAttemptLimit = 0
+preReqs = []
+assets = ["download.zip"]
+additionalLinks = ["https://example.invalid/rules"]
+instanced = false
+instance_expiration = 300
+
+[[challenge.metadata.hints]]
+text = "A bounded hint"
+points = 50
```
-### Challenge Metadata
-
-This section contains metadata information about the challenge and is consumed by beast to be provided to
-the user.
+`flag` may be empty only when `dynamicFlag = true`. `maxAttemptLimit = 0` means unlimited attempts. Point ranges must be internally consistent. Prerequisites must be valid challenge names, links must be HTTP(S), and each asset must exist beneath `static_dir` (default `public`).
-Structure of the sections with the acceptable fields are:
+Instanced challenges create per-user runtime instances. Expiration falls back to the global instance default when omitted; extensions and per-user counts are capped globally.
-```toml
-# Required Fields
-flag = "" # Flag for the challenge
-name = "" # Name of the challenge
-type = "" # Type of the challenge, one of - Get available types from /api/info/types/available
-description = "" # Descritption for the challenge.
-
-# Optional fields.
-tags = ["", ""] # Tags that the challenge might belong to, used to do bulk query and handling eg. binary, misc etc.
-hints = ["", ""]
-minPoints = 0 # Minimum points given to the player for correct flag submission. Beast has dynamic scoring, so a range of points is specified
-maxPoints = 0 # Maximum points given to the player for correct flag submission. Beast has dynamic scoring, so a range of points is specified
-assets = ["", ""] # Name of assets to be provided which are included in the ./static folder
-```
+## Generated environments
-### Challenge Environment
-
-This is the core of deployment configuraiton for the challenge which is consumed by beast.
-It contains all the information required by beast to manage the lifecycle of the challenge.
-
-Acceptable fields for this section are:
+Non-static generated challenge images use `[challenge.env]`:
```toml
-# Ports to reserve for the challenge, we bind only one of these to host other are for internal communictaions only.
-# Should be within a particular permissible range.
-ports = [0, 0]
-default_port = 0 # Default port to use for any port specific action by beast.
-
-# Port mapping is the array of port mapping from host to container.
-# The first port mentioned in the mapping is the host port and the second is the container port.
-# Port Mapping is given preference as compared to ports, so if you have a port and the same port in mapping
-# then the host port corresponding to container port in the port mapping.
-port_mappings = ["10005:80"]
-
-
-# Dependencies required by challenge, installed using default package manager of base image apt for most cases.
-apt_deps = ["", ""]
-
-
-# A list of setup scripts to run for building challenge enviroment.
-# Keep in mind that these are only for building the challenge environment and are executed
-# in the iamge building step of the deployment pipeline.
-setup_scripts = ["", ""]
-
-
-# A directory containing any of the static assets for the challenge, exposed by beast static endpoint.
-static_dir = ""
-
-
-# Command to execute inside the container, if a predefined type is being used try to
-# use an existing field to let beast automatically calculate what command to run.
-# If you want to host a binary using xinetd use type service and specify absolute path
-# of the service using service_path field.
-run_cmd = ""
-
-
-# Similar to run_cmd but in this case you have the entire container to yourself
-# and everything you are doing is done using root permissions inside the container
-# When using this keep in mind you are root inside the container.
-entrypoint = ""
-
-
-# Relative path to binary which needs to be executed when the specified
-# Type for the challenge is service.
-# This can be anything which can be exeucted, a python file, a binary etc.
+[challenge.env]
+ports = [8080]
+default_port = 8080
+apt_deps = []
+setup_scripts = ["setup.sh"]
+static_dir = "public"
+base_image = "ubuntu:24.04"
+run_cmd = "./server"
service_path = ""
+web_root = "challenge"
+entrypoint = ""
+docker_context = ""
+xinetd_conf = ""
+traffic = "tcp"
+[[challenge.env.var]]
+key = "FLAG_FILE"
+value = "secrets/flag"
+```
-# Relative directory corresponding to root of the challenge where the root
-# of the web application lies.
-web_root = ""
+Rules:
+- One to three unique container ports in `1..65535` are allowed; `default_port` must be one of them.
+- `traffic` is `tcp` or `udp`.
+- `base_image` must be in the administrator allowlist.
+- Setup scripts run at image-build time and therefore are trusted code.
+- Environment `value` is a path to a file inside the challenge, not a literal secret. Beast reads the file and injects its content.
+- `run_cmd` and `entrypoint` are mutually exclusive. An explicit entrypoint may run as the image user/root; use it only when required.
+- `docker_context` names a Dockerfile inside the challenge. Custom Dockerfiles are trusted build code.
-# Any custom base image you might want to use for your particular challenge.
-# Exists for flexibility reasons try to use existing base iamges wherever possible.
-base_image = ""
+Static challenges need only `static_dir`; no port or runtime command is required.
+## Compose environments
-# Docker file name for specific type challenge - `docker`.
-# Helps to build flexible images for specific user-custom challenges
-docket_context = ""
+```toml
+[challenge.env]
+docker_compose = "docker-compose.yml"
+default_port_var = "APP_PORT"
+static_dir = "public"
+```
+Compose port bindings use variables assigned by Beast, for example `${APP_PORT}:8080`. Only port interpolation is accepted. The parser rejects unknown fields and security-sensitive runtime controls including privileged mode, host network/PID/IPC, devices, Docker socket access, unsafe mounts, namespace sharing, added capabilities, and arbitrary restart ownership. Build contexts and Dockerfiles must remain inside the challenge directory, and every service must comply with global resource ceilings.
-# Environment variables that can be used in the application code.
-[[var]]
- key = ""
- value = ""
+When `docker_compose` is present, generated-image fields such as `run_cmd`, `entrypoint`, `base_image`, `apt_deps`, and `setup_scripts` are ignored.
-[[var]]
- key = ""
- value = ""
+## Resources
-# Protocol supported by the challenge, currently supported are tcp and udp.
-traffic = "tcp"/"udp"
+```toml
+[resource]
+cpu_shares = 256
+cpuslimit = 0.20
+memory_limit = 268435456
+pids_limit = 64
```
-If you want to checkout some example challenge configuration, checkout `_example` directory in the
-root of the repository. It has a bunch of challenge templates example to get started with. Pick one from
-there and start building your own challenge.
+Omitted or zero values inherit global defaults. A challenge may request less, never more, than the configured global ceilings.
-## Note
+## Archives and uploads
-We currently don't do automatic port management for challenge, it is mostly due to historic
-reasons. Beast still handles challenge deployment for [Backdoor](https://backdoor.sdslabs.co/) which has a different database
-as that of beast and to have the port synced among these two database is not easy so for the initial
-milestone of beast we targatted static ports.
+Uploaded ZIP/TAR content is bounded by entry count and expanded size. Absolute paths, `..`, duplicate paths, symlinks, devices, FIFOs, sockets, and overwrite attempts are rejected. Staging copies regular files only and never follows links.
diff --git a/docs/ChallTypes.md b/docs/ChallTypes.md
index 90f2b46e..a936908b 100644
--- a/docs/ChallTypes.md
+++ b/docs/ChallTypes.md
@@ -1,66 +1,29 @@
-# Challenge Types
+# Challenge types
-## Service Challenge
+## Static
-Any service whether it is a binary file, or a shell script, which needs to be instantiated on every connection can be easily hosted using `service` type challenge. **Xinetd** is for hosting these type of challenges inside a docker container.
+`type = "static"` publishes files from `static_dir` (default `public`) through the shared unprivileged static service. Static challenges do not receive a runtime container or port.
-###Primary Requirements
+## Service
-```toml
-# Relative path to binary or script which needs to be executed when the specified
-# Type for the challenge is service.
-# This can be anything which can be exeucted, a python file, a binary etc.
-service_path = ""
-```
+`type = "service"` hosts a program through xinetd. Set `service_path` to a regular executable/script inside the challenge and provide one or more `ports`. An optional `xinetd_conf` replaces the generated service configuration.
-## Web Challenge
+## Web
-Web challenges are hosted using the corresponding images from Dockerhub. Currently only these types are supported:
+Web types begin with `web`, such as the supported PHP/Python/Node variants returned by configuration helpers. Set `web_root` and ports, or provide a validated `docker_context`/`docker_compose` for a custom web stack.
-* Node
-* Python : Django and Flask
-* Php
+## Bare/custom
-###Primary Requirements
+`type = "bare"` uses a generated base image and requires `run_cmd` or `entrypoint`. Generated `run_cmd` execution uses the unprivileged challenge user. An explicit entrypoint controls the whole container and may execute with its image user/root privileges, so it should be treated like a custom Dockerfile.
-```toml
-# Relative directory corresponding to root of the challenge where the root
-# of the web application lies.
-web_root = ""
-```
+## Custom Dockerfile
-## Static Challenge
+Set `docker_context` to the Dockerfile path inside the challenge. Beast validates containment and applies build/run resource controls, but Dockerfile instructions are trusted build code with Docker-daemon impact.
-All the challenges which requires the hackers to only have static files comes under `static` challenges. All the files are mounted on a single container which serves all the static files to the hackers.
+## Docker Compose
-## Bare Challenge
+Set `docker_compose` to a Compose file inside the challenge. Beast accepts a constrained service schema, validates resource ceilings and build contexts, allocates port variables, and rejects host-level privilege controls. See [Challenge configuration](ChallConfig.md#compose-environments).
-A challenge which requires high level of customization can be hosted using `bare` challenge. In these case, a bare base image is provided with exposed ports.
+## Instanced challenges
-###Primary Requirements
-
-```toml
-# Command to execute inside the container, if a predefined type is being used try to
-# use an existing field to let beast automatically calculate what command to run.
-run_cmd = ""
-
-# OR
-
-# Provide a script to run on startup of container
-# Similar to run_cmd but in this case you have the entire container to yourself
-# and everything you are doing is done using root permissions inside the container
-# When using this keep in mind you are root inside the container.
-entrypoint = ""
-```
-
-## Docker Challenge
-
-Authors might have tested the challenges in a isolated docker environment and might not want to port the challenge to one of these types. So they can use `docker` type challenge in which you can provide your own docker context file and ports.
-
-###Primary Requirements
-
-```toml
-# Docker file name for specific type challenge - `docker`.
-# Helps to build flexible images for specific user-custom challenges
-docket_context = ""
-```
+Any supported runtime type may set `instanced = true`. Beast then creates isolated per-user instances with Redis-backed expiry, bounded extension, and administrator cleanup endpoints. Static-only challenges are not meaningful as instanced runtimes.
diff --git a/docs/Deployment.md b/docs/Deployment.md
index cd04187d..d4443d08 100644
--- a/docs/Deployment.md
+++ b/docs/Deployment.md
@@ -1,26 +1,18 @@
# Deployment
-Deploying challenge is completely handled by beast, once the author is done with creating the required
-challenge he can invoke deploy endpoints from beast to trigger the deployment pipeline for the challenge.
+Beast can deploy a validated local challenge directory or synchronize the `challenges/` tree from configured SSH Git remotes. Active remotes are treated as sources of trusted build code.
-The deployment model for beast is based on `git`, you can think of a git repository as a single source of
-truth for all the challenge configuraiton. Using this `gitops` based approach provides a lot of benifits
-in terms of flexibility and robustness of the applications.
+The deployment pipeline validates configuration and paths, copies regular files into private staging, builds a generated/custom image or constrained Compose project under resource limits, selects a worker, starts the runtime, records identifiers/ports transactionally, and publishes normalized static assets when configured.
-Every challenge that we create/add to the repository we can first verify is working and then can also do an
-automatic deployment for the same. This is quite helpful for wargames like website where earlier for each challenge you
-would have to manually deploy the challenge.
+Remote synchronization and startup fail when an active remote/worker is unavailable; Beast does not silently operate with incomplete capacity. Periodic synchronization is opt-in with `beast run --periodic-sync`, and the scheduler prevents overlapping runs of the same task.
-Using this gitops based approach provides us with all the benifits that modern days deployment pipelines have. Also,
-it helps to easily extend the use cases around how beast can be used in different types of scenarios like for Jeopardy style
-challenges, Wargames websites, CTF competitions etc.
+Before production deployment:
-## Flow
+- review every setup script, Dockerfile, entrypoint, and Compose image as executable trusted code;
+- pin base images by digest where reproducibility is required;
+- keep flags/secrets out of Git and image layers, using validated file-backed environment values or runtime mechanisms;
+- enforce worker firewall rules around allocated port ranges;
+- terminate public challenge HTTP/TCP services appropriately and keep the Beast management API private over HTTPS;
+- verify backup and restore procedures independently of controller shutdown.
-
-
-## Note
-
-There are a lot of features which we can have when using a git repository as the source of truth for the application.
-Beast still does not make use of all of them. There are a few features which are still in pipeline, in the same context
-you can hope to see them in future releases of beast.
+Normal controller shutdown preserves deployed workloads. Use explicit challenge undeploy/purge operations when runtime removal is intended.
diff --git a/docs/Documentation.md b/docs/Documentation.md
index 5a2579ad..534ce053 100644
--- a/docs/Documentation.md
+++ b/docs/Documentation.md
@@ -1,16 +1,5 @@
-# Beast Documentation
+# Documentation map
-This directory contains documentation related to beast and will guide you through flow, architecture usage and gotchas of beast.
+Use [Setup](Setup.md) for installation and operator security, [Getting started](GettingStarted.md) for the first challenge, [Usage](Usage.md) for CLI/API operation, and [Challenge configuration](ChallConfig.md) for the strict schema.
-Move over to any of the below pages to know more about beast.
-
-## Index
-
-* [Usage](./Usage.md)
-* [Setup](./Setup.md)
-* [Getting Started](./GettingStarted.md)
-* [Features](./Features.md)
-* [Architecture](./Architecture.md)
-* [Authentication Flow](./APIAuth.md)
-* [Challenge Configuration](./ChallConfig.md)
-* [Deployment](./Deployment.md)
+The [Architecture](Architecture.md) page documents state ownership and trust boundaries. The [command reference](cmdref/beast.md) is generated from the current binary. The running controller serves current Swagger API documentation at `/api/docs/index.html` over HTTPS.
diff --git a/docs/Features.md b/docs/Features.md
index 4625db7a..f53cc201 100644
--- a/docs/Features.md
+++ b/docs/Features.md
@@ -1,71 +1,28 @@
# Features
-### Git based source of truth
+## Challenge lifecycle
-- Single or multiple git repositories as reliable source of truth for all the challenges.
+- Local directories or one/more SSH-authenticated Git remotes as challenge sources.
+- Static, generated service/web/bare images, custom Dockerfiles, and constrained Compose projects.
+- Deploy, undeploy, redeploy, purge, logs, status, health probes, and administrator-controlled container execution.
+- Bounded asynchronous worker queue with task de-duplication and surfaced failures.
+- Local and SSH Docker workers with per-worker port ranges and round-robin placement.
-- Easy to collaborate similar to an open source application where everyone can give their reviews on your contribution.
+## Competition services
-- Automatic deployment of challenges by using a triggering pipeline in conjunction with beast web server, this can
- be configured in many ways:
- - Execute a dry run on the challenge using beast.
- - Using github webhook trigger deploy on beast when a challenge in pushed.
+- Password login with role-scoped JWT authorization.
+- Contestant registration/password reset, submissions, hints, prerequisites, attempt limits, and cheating records.
+- Optional dynamic scoring and freeze/unfreeze leaderboard behavior.
+- Per-user instances with expiration, extension limits, reconciliation, and administrator cleanup.
+- Slack/Discord notifications and SSE updates.
-### Container based Isolation
+## Safety controls
-We use containers as the atomic source of handle for each of our challenge. They provides us with an isolated and secure
-environment for the challenges.
+- Mandatory HTTPS API with timeouts, request-size bounds, strict CORS, and controlled panic responses.
+- Strict global/challenge TOML and constrained Compose parsing.
+- Contained archive extraction and staging that reject traversal, links, special files, duplicates, and expansion abuse.
+- Default/hard-ceiling CPU, memory, and PID limits.
+- Verified SSH host keys and mandatory verified transport for remote PostgreSQL/Redis.
+- Unprivileged, digest-pinned static service with per-challenge read-only mounts.
-- Currently only docker based container runtime support is available but we are extending to create a generalized
- container runtime interface. This also means your documentation can now easily stand on its own, without always
- plementation to support multiple providers similar to what kubernetes does.
-
-- Optionally security or sandboxing capabilities can be further enhanced by using more secure runtime like `runsc` in place
- of runc.
-
-- We are also looking to support VM based implmentation in place of these containers such as firecracker, kata containers,
- intel clear containers etc.
-
-### Easy Configuration
-
-Beast provides an easy configuration interface which allows the challenge creator to focus on only one problem which
-is creating the challenge rather than thinking about the deployment scenarios for the same. There is a minimal overhead
-due to simplicity of configuration that the author goes through during challenge creation.
-
-Configuration is even less of a pain due to great sensible defaults provided beast which works for most of the cases but are of
-course configurable.
-
-To know more about configuration parameters provided by beast move to [this section](ChallConfig.md)
-
-### SSH support for challenge instances
-
-Beast provides challenge author access to each instance of all the challenges so that these challenges can be
-debugged on the fly in case there is a need for. This also means that challenge author can test/debug the challenges before publishing them.
-
-For challenges which needs high degree of customization the author can create the environment by `SSH`ing to the container
-and then export the running container image getting a tarball which can be used later to reproduce the
-environments.
-
-### Miscellaneous
-
-- Web and Command line interface to perform actions.
-
-- REST API interface for the entire ecosystem. The competition related APIs can be integrated with your choice of frontend platform.
-
-- Ability to host full fledged CTF competition on beast itself.
-
-- An optional automated health check service to periodically check the status of challenges and report if there is
- some sort of problem with one.
-
-- Single source of truth for all the static content related to all the challenges making it easy to debug, monitor and manage
- static content through a single interface.
-
-- Support for various notification channels like slack, discord.
-
-- Everything embedded to a single go binary which can be easily used anywhere.
-
-- Extensible and flexible structure for easier development and feature introduction, some features we are exploring to support
- with beast are:
- - Kubernetes(k8s or k3s) based control plane to manage the lifecycle of challenges.
- - Multi server support.
- - Dry run support to help locally support development of challenges.
+Docker-backed builds still execute trusted organizer code with root-equivalent daemon authority. These controls reduce mistakes and runtime exposure; they do not make hostile Dockerfiles or kernel exploits safe.
diff --git a/docs/GettingStarted.md b/docs/GettingStarted.md
index e6f676af..5d948f37 100644
--- a/docs/GettingStarted.md
+++ b/docs/GettingStarted.md
@@ -1,152 +1,13 @@
-# Getting Started
+# Getting started
-Beast is a tool for automatic deployment of CTF type challenges, the intial aim of the project
-was to asist the deployment for challenges on backdoor.sdslabs.co, but since its inception beast has grown a lot beyond that scope.
-It is a general tool for deployment of Jeopardy style CTF challenges and is not coupled with backdoor anymore.
+1. Follow [Setup](Setup.md) to build Beast, configure TLS/PostgreSQL/Redis, and create an administrator.
+2. Start the controller with `beast run --health-probe`.
+3. Authenticate with `beast getauth --host https://localhost:5005 --ca-file "$HOME/.beast/secrets/tls.crt" --username `.
+4. Create an empty challenge directory and run `beast new` inside it.
+5. Edit the generated `beast.toml` and place static files in `public/`.
+6. Run `beast verify --local-directory "$PWD"`.
+7. As an administrator on the controller, deploy locally with `beast challenge deploy --local-directory "$PWD"`; otherwise commit the directory beneath `challenges/` in a configured SSH Git remote or upload a bounded ZIP through the API.
-The main hurdle for deployment of such challenges is the requirement of stong isolation
-of the environment in which the challenges are being deployed, docker provides us with all the
-sandboxing we need with minimum overhead. There are more secure runtime like `runsc`(gVisor) which can
-be used to improve the sandboxing capabilities of the containers.
+Use lowercase challenge names containing only letters, digits, dots, underscores, and hyphens. Do not include symlinks, device files, sockets, or paths outside the challenge root.
-There are a lot of features that comes embedded with beast, take a look [here](Features.md)
-
-Beast comes in with an embedded web server which can be used as an interface for interacting with beast.
-The web server is built with gin framework and is very performant, to run the server with debugging mode on
-use the following command
-
-```bash
-beast run -v -p 3333
-```
-
-To interact with beast you need to first authenticate yourself, currently this process is a little bit tedious since we
-don't currently have a strict database storing the details of our users. To check out how the authentication
-flow works for beast take a look [here](APIAuth.md)
-
-Once you have the authentication token with you all you need to do is Embed the token in Headers of your request as
-`Authorization: Bearer `.
-
-There is swagger generated API documentation with the details of endpoints exposed by beast which can be used for interacting
-with the web server.
-
-## Deploying your first challenge
-
-In this section we will try to create a new challenge and deploy it using beast. For the simplicitiy of this tutorial we are
-deploying a simple buffer overflow challenge.
-
-The source code for our challenge file is:
-
-```c
-#include
-#include
-
-int sample()
-{ FILE *ptr_file;
- char buf[100];
-
- ptr_file = fopen("flag.txt","r");
- if (!ptr_file)
- return 1;
-
- while (fgets(buf,100, ptr_file)!=NULL)
- fprintf(stderr, "%s",buf);
- fclose(ptr_file);
- return 0;
-}
-
-void test()
-{ char input[50];
- gets(input);
- sleep(1);
- fprintf(stderr, "ECHO: %s\n",input);
-}
-
-int main()
-{ test();
- return 0;
-}
-```
-
-Create a new directory for the challenge and create the source code file(`pwn_me.c`) with the above contents.
-
-Create a file beast.toml with the following contents which defines the configuration of the challenge
-
-```toml
-[author]
-name = "fristonio" # Name of the challenge creator
-email = "deepeshpathak09@gmail.com" # Email for contact
-ssh_key = "ssh-rsa AAAAB3NzaC1y..." # Public SSH key for the challenge author
-
-[challenge.metadata]
-name = "PWN-TEST" # Name of the challenge, must be same as the directory name.
-flag = "FLAG{TEST_CHALLENGE_PWNED}" # Flag for the challenge
-type = "service" # Type of challenge
-
-# This section defines the environment for the challenge
-[challenge.env]
-
-# Define the dependencies we might need for the challenge.
-# For example in this case we need gcc for compilation of the source file
-apt_deps = ["gcc", "socat"]
-
-# The relative path of the binary or executable which we should
-# run for each connection to the challenge.
-service_path = "pwn"
-
-# Port to run the challenge on.
-ports = [10003]
-
-# Since we still haven't defined how we are going to compile the source
-# code, these scripts are for setting up the environment.
-setup_scripts = ["setup.sh"]
-```
-
-The above configuration is simple and straightforward, from the perspective of the challenge
-creator it simply asks how will he run the challenge locally. So for example he needs to install
-some dependencies first like gcc, then he compiles the source and generates a binary, then he serves
-the binary by exposing it as a service at some port. So from a challenge creator perspective
-this is not a big hurdle.
-
-Let's see how our setup scripts look like.
-
-```bash
-set -e
-
-gcc -o pwn pwn_me.c
-```
-
-* All the commands that you execute are executed from the within the root of the challenge
-directory.
-
-It's simple we just compile the source code.
-
-The final step is important and needed to be taken care of. We have setup the challenge but now we also
-need to provide the binary we obtained to the participant as part of the challenge.
-
-Beast provides a way to do this using a special file named `post-build.sh`. This script is run once finally
-after all our environment setup is done, in this file you can read/write/modify the final environment once more
-before finally coming to the challenge. Up until this point we have the binary with us, we can write this script
-to copy the final binary to the publically available directory within the challenge named `public/`.
-
-This directory(`public/`) is exposed by beast using the static content provider. To make any file from within your challenge
-publically accessible as part of the challenge you need to put that file in this directory rest is handled by beast itself.
-
-This is how our `post-bulid.sh` script looks like for this particular challenge
-
-```bash
-#!/bin/bash
-
-set -euxo pipefail
-
-cp pwn public/
-```
-
-And that's it, we have our challenge ready to be deployed by beast.
-
-## Deployment using beast
-
-We have our challenge ready with the required configuration. To deploy the challenge check out the Deployment flow [here](Deployment.md).
-
-## Note
-
-* To know more about the environment configuration possiblities with beast head out to [this section](ChallConfig.md).
+The generated scaffold is a static challenge. For service, web, custom Dockerfile, Compose, and instanced variants, see [Challenge types](ChallTypes.md), [Challenge configuration](ChallConfig.md), and the repository's `_examples/` directory.
diff --git a/docs/README.md b/docs/README.md
index f879ad88..149ed6b8 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -1,43 +1,19 @@
-# Beast Documentation
+# Beast documentation
-This directory contains documentation related to beast and will guide you through flow, architecture usage and gotchas of beast.
+Beast builds and operates CTF challenges across local or remote Docker workers through an authenticated HTTPS API.
-## Index
+Start here:
-* [Usage](Usage.md)
-* [Setup](Setup.md)
-* [Getting Started](GettingStarted.md)
-* [Features](Features.md)
-* [Architecture](Architecture.md)
-* [Authentication Flow](APIAuth.md)
-* [Challenge Types](ChallTypes.md)
-* [Challenge Configuration](ChallConfig.md)
-* [Sample Challenges](SampleChallenges.md)
-* [Deployment](Deployment.md)
+- [Setup](Setup.md): host, datastore, TLS, worker, and teardown requirements.
+- [Usage](Usage.md): CLI and API operation.
+- [Authentication](APIAuth.md): login, JWT, roles, and CORS.
+- [Challenge configuration](ChallConfig.md): strict `beast.toml` schema and safety rules.
+- [Challenge types](ChallTypes.md): static, service, web, bare/custom, and Compose models.
+- [Architecture](Architecture.md): state, queues, workers, reconciliation, and trust boundaries.
+- [Command reference](cmdref/beast.md): generated from the current binary.
+The live server exposes generated Swagger documentation at `/api/docs/index.html` over HTTPS.
-## Intro
+## Operator warning
-Beast is a service that runs on your host(may be a bare metal server or a cloud instance) and helps in the mangement of deployment, lifecycle and health check of CTF challenges. Beast is created to automate and ease the deployment procedure of challenges for a Jeopardy style CTF competition. As of now beast support the following type of challenges:
-
-* Service - A service hosted on beast container instance
-* Web - Web based challenges for various languages including PHP, Python, Node.js etc.
-* Static - Challenges with static files, this may include forensics challenges.
-* Bare - Highly customisable challenges.
-* Docker - Challenges which are provided with their own docker file.
-
-## Tech Stack
-
-Beast is written completely in Golang and comes with a clean REST API interface to trigger actions or interact with underlying functionalities.
-The REST API server is implemented using `gin` go library and uses JWT as an authentication mechanism. Being written in go, Beast is compiled into
-a single binary which can run on any linux distribution.
-
-Beast uses Docker as a container runtimes to run challenges in a sandboxed environment. Note that container does not provide a very strong isolation, but our host is safe as long as there is no 0-day in linux kernel itself. Even though container provide a security layer for the challenges, we follow some practices to harden those security measures.
-
-We use Swagger for automatic generation of API documentation and you can find the docs at `/api/docs/index.html` from beast server root.
-
-To save the state of the deployments and challenges beast uses SQLite as a database, all the information ranging from challenge deployment state to allocated ports and author information is stored in this database. This database is created automatically in the root of your beast configuration directory.
-
-### Note
-
-* To run challenges in a highly secure mode you can change the runtime for docker(by default it is runc) to gVisor(runsc) which provides a much better security layer in the containerized sandboxed environment.
+Access to Docker is root-equivalent, and challenge build contexts contain executable organizer code. Run Beast on dedicated infrastructure, protect its configuration/keys, restrict management API reachability, and use verified TLS for every non-loopback dependency.
diff --git a/docs/SampleChallenges.md b/docs/SampleChallenges.md
index 3ffeacd2..23a1cf39 100644
--- a/docs/SampleChallenges.md
+++ b/docs/SampleChallenges.md
@@ -1,186 +1,20 @@
-# Sample Challenges
+# Sample challenges
-Below are some of the sample challenges wrapped in the format that beast can understand and deploy.
+The maintained examples live in `_examples/`:
-## Service Challenge
+- `static-chall`: shared static service.
+- `service` and `xinetd-service`: generated/custom xinetd services.
+- `web-php` and `web-php-mysql`: generated web environments.
+- `bare-docker`: custom Dockerfile challenge.
+- `compose-type`: constrained multi-service Compose challenge.
+- `instanced-service` and `instanced-compose`: per-user runtime instances.
-These type of challenges need not only consist of binary challenges any executable can be hosted using service type
-challenge which includes scripts too. Shell scripts, ruby scripts, python scripts can all be hosted using this type, make
-sure you include the proper shebang at the top of script if you are hosting scripts
-For binary service type challenges the author needs to build the binary first, this can be done using the setup scripts.
-
-```toml
-[author]
-name = "fristonio"
-email = "deepeshpathak09@gmail.com"
-ssh_key = "ssh-rsa AAAAB3NzaC1y"
-
-[challenge.metadata]
-name = "sample"
-flag = "CTF{sample_flag}"
-type = "service"
-points = 40
-
-[challenge.env]
-apt_deps = ["gcc", "socat"]
-setup_scripts = ["setup.sh"]
-service_path = "pwn"
-ports = [10001]
-```
-
-Setup script
+Validate an example before deployment:
```bash
-set -e
-
-gcc -o pwn pwn_me.c
-
-exit 0
-```
-
-## Docker Challenge
-
-To further improve the customizability/flexibility of challenge deployment a docker type challenge is provided. This can
-be used in cases when the author knows how to create docker images, this brings to table the customization of the entire
-container environment that beast can deploy.
-
-Any existing dockerized challenge can be easily ported to beast by simply creating the `beast.toml` configuration file with only
-a few required fields.
-
-```toml
-[author]
-name = "fristonio"
-email = "deepeshpathak09@gmail.com"
-ssh_key = "ssh-rsa AAAAB3NzaC1y"
-
-[challenge.metadata]
-name = "docker-type"
-flag = "CTF{sample_flag}"
-type = "docker"
-points = 50
-
-[challenge.env]
-docker_context = "Dockerfile"
-ports = [10002]
+beast verify --local-directory "$PWD/_examples/service"
```
-```Dockerfile
-FROM ubuntu:16.04
-
-RUN apt-get update \
- && apt-get install -y gcc socat
-
-COPY script.sh /script.sh
-COPY entrypoint.sh /entry.sh
-COPY flag /flag
-
-EXPOSE 10002
-
-RUN chmod +x /script.sh
-RUN chmod +x /entry.sh
-
-CMD ["/entrypoint.sh"]
-```
-
-## Static challenges
-
-Some challenge don't need a complete environment to run, they just need to serve some static files to the user. This
-happens a lot in case of forensics challenges. Beast optimizes the deployment of such challenges by creating just a single
-environment(nginx based file server) for all such challenges.
-
-```toml
-[author]
-name = "fristonio"
-email = "deepeshpathak09@gmail.com"
-ssh_key = "ssh-rsa AAAAB3NzaC1y"
-
-[challenge.metadata]
-name = "static-challenge"
-flag = "CTF{sample_flag}"
-type = "static"
-points = 100
-```
-
-In the above case all the files that are present in the challenge root are available for the player to download.
-
-### Note
-
-Every challenge in all the above provided types have a way to provide static files for the user to download, this can be
-done using the `static_dir` in the challenge configuration. Every file in the provided `static_dir` directory will be provided
-for download as a static asset.
-
-## Bare beast Challenge
-
-For these type challenges the key is the `run_cmd` configuration parameter in the `beast.toml` file. This
-consist of the command to run for the container, any challenge can be deployed using this type which can
-be customized by the using the corresponding `run_cmd`.
-
-For example a django challenge can be deployed by poviding `python manage.py` in the `run_cmd`.
-
-```toml
-[author]
-name = "fristonio"
-email = "deepeshpathak09@gmail.com"
-ssh_key = "ssh-rsa AAAAB3NzaC1y"
-
-[challenge.metadata]
-name = "sample"
-flag = "CTF{sample_flag}"
-type = "bare"
-hints = ["simple_hint_1", "simple_hint_2"]
-
-[challenge.env]
-apt_deps = ["gcc", "socat"]
-setup_scripts = ["setup.sh"]
-run_cmd = "socat tcp-l:10003,fork,reuseaddr exec:./pwn"
-ports = [10003]
-```
-
-Setup script
-
-```bash
-set -e
-
-gcc -o pwn pwn_me.c
-
-exit 0
-```
-
-## Web Challenges
-
-Beast can be used to deploy different type of challenges when in comes to web category. Currently beast supports the following type of web challenges
-
-* PHP
-* Node
-* Python - Django and Flask
-
-The type and environment of the challenge is decided by `type` field in the `challenge.metadata` section. The format of which is -
-`web:::`
-
-### Sample PHP challenge
-
-```toml
-[author]
-name = "fristonio"
-email = "deepeshpathak09@gmail.com"
-ssh_key = "ssh-rsa AAAAB3NzaC1y"
-
-[challenge.metadata]
-name = "web-php"
-flag = "CTF{sample_flag}"
-type = "web:php:7.1:cli"
-
-[challenge.env]
-ports = [10002]
-web_root = "challenge"
-default_port = 10002
-```
-
-The `web_root` is the base directory for the php server to locate the files.
-
-The type of challenge consist of the following format - `web:php::`
-
-#### Note
+Deploying examples executes their setup scripts or Docker build instructions. Review them as code and use a disposable development worker. Example flags and passwords are intentionally public and must never be reused in a competition.
-Make sure that you are installing all the mysql related dependencies in the `apt_deps` configuration parameter. As in the
-above case `php*-mysql`
+Compose examples use `${VARIABLE}:container-port` mappings. Beast supplies host ports from the selected worker's configured range; hard-coded host ports and arbitrary interpolation are not supported.
diff --git a/docs/Setup.md b/docs/Setup.md
index 40c506cc..38fdad12 100644
--- a/docs/Setup.md
+++ b/docs/Setup.md
@@ -1,222 +1,103 @@
-# Setting up Beast
+# Setup
-Beast compiles to a single Golang binary which can be used anywhere.
+## Host assumptions
-## Build
+Beast is supported on Linux and requires Go 1.23+, Docker Engine, Git, Make, PostgreSQL, and Redis. Install them from trusted packages. `scripts/installenv.sh` verifies Go, Git, Make, and a usable Docker daemon; it deliberately does not install packages or alter system services.
-To build beast from source follow the instruction below
+Docker control is root-equivalent. Use a dedicated Beast account and host/VM, keep the management API private, and do not share that account with untrusted users.
-- Make sure you have docker and golang installed in your system and have it in `$PATH`
+## Build
```bash
-$ mkdir -p $GOPATH/src/github.com/sdslabs/
-
-$ git clone https://github.com/sdslabs/beastv4 $GOPATH/src/github.com/sdslabs/beastv4
-
-$ cd $GOPATH/src/github.com/sdslabs/beastv4
-
-# Build beast
-$ make build
-
-# Build additional tooling required with beast.
-$ make tools
+git clone https://github.com/sdslabs/beastv4.git
+cd beastv4
+./scripts/installenv.sh
+make build
+beast version
```
-Building beast with above method will copy the beast binary to `$GOBIN` so make sure that it is in your `$PATH`
-
-Run `beast version` to check if beast is build with the commit that you pulled the source code with.
-
-```bash
-[fristonio] $ ~/golang/src/github.com/sdslabs/beastv4 documentation
-❮❮ beast version
-
- ****************** Beast ******************
-
- Version : 0.1
- Revision : 2b9cd25
- Branch : master
- Build-User : fristonio@fristonio
- Build-Date : 20190713-22:50:27
- Go-Version : 1.11
-
- *******************************************
-```
-
-## Configure
-
-All the beast related files lies in `$HOME/.beast` directory. Create this directory which will be used by beast.
-
-Create a configuration file for beast in the root of beast configuration directory(`$HOME/.beast`) named `config.toml`, an example configuration for the same is present in `/_examples/example.config.toml`.
-
-### Configuration file
-
-The structure of the configuration file used by beast is as follows
-
-```toml
-# Authorized key file used by ssh daemon running on the host
-# This is used for forwarding ssh connection to docker containers, the
-# access to a container is only given to the author of the challenge.
-authorized_keys_file = "/home/fristonio/.beast/beast_authorized_keys"
-
-
-# Directory which will contain all the autogenerated scripts by beast
-# These scripts are the heart to above authorized keys file. Each entry in authorized
-# keys file as a corresponding script which is executed during an SSH attempt.
-scripts_dir = "/home/fristonio/.beast/scripts"
-
-
-# Base OS image that beast allows the challenges to use.
-allowed_base_images = ["ubuntu:18.04", "ubuntu:16.04", "debian:jessie"]
+Set `BEAST_OUTPUT=/absolute/path/beast` when a location other than `$(go env GOPATH)/bin/beast` is desired.
+## PostgreSQL and Redis
-# For authentication purposes beast uses JWT based authentication, this is the
-# key used for encrypting the claims of a user. Keep this strong.
-jwt_secret = "beast_jwt_secret_SUPER_STRONG_0x100010000100"
+Create or select PostgreSQL and Redis services before initialization. Beast can provision its application database/user and Redis ACL user, but it needs administrator credentials during `beast init`.
+For loopback-only development, PostgreSQL may use `sslmode = "disable"` and Redis may use `tls = false`. For any non-loopback address:
-# To allow beast to send notification to a notification channel povide this webhook URL
-# We are also working on implmeneting notification using IRC.
-[[notification_webhooks]]
+- PostgreSQL must use `sslmode = "verify-full"` and `sslrootcert` must identify a readable CA bundle.
+- Redis must use `tls = true`; configure `ca_file` when the service CA is not in system roots and `server_name` when it differs from the host.
-# The webhook URL of notification channel where notification should be sent
-url = ""
+Beast stores only its own keys under `beast:*` and creates a command-scoped Redis ACL. Do not reuse the configured application credentials as datastore administrator credentials.
-# The service name to be used. It can be `discord` and `slack`
-service_name = "discord"
+## Initialize
-# Status of webhook URL to be used.
-# If it is false then notification will not be sent on this URL
-active = true
+The recommended interactive path is:
+```bash
+beast init
+```
-# The frequency for any periodic event in beast, the value is provided in seconds.
-# This is currently only used for health check periodic duration.s
-ticker_frequency = 3000
-
-
-# Container default resource limits for each challenge, this can be
-# Overridden by challenge configuration beast.toml file.
-default_cpu_shares = 1024
-default_memory_limit = 1024
-default_pids_limit = 100
-
-
-# Configuration corresponding to the remote repository used by beast
-# We use ssh authentication mechanism for interacting with git repository.
-[[remote]]
-
-# URL of the remote git repository, this should be user@host: format
-url = "git@github.com:sdslabs/hack-test.git"
-
-# Name of the remote
-name = "hack-test"
-
-# Branch we are tracking the remote in beast.
-branch = "master"
-
-# Path to private SSH key for interacting with the git repository.
-ssh_key = "/home/fristonio/.beast/secrets/key.priv"
-
-# Status of remote git repository URL to be used
-# If it is set to false then that remote git repository will not be used
-active = true
-
-# The following fields are required only while hosting a competition on beast
-# This section contains information about the competition to be hosted
-# Structure of the sections with the acceptable fields are:
-
-# Required Fields
+Initialization:
-# Name of the competition
-name = ""
+1. Creates `$HOME/.beast` and private subdirectories with mode `0700`.
+2. Creates a mode-`0600` configuration and prompts for datastore, worker, resource, and competition settings.
+3. Generates a localhost-only development certificate when the default TLS files are both absent.
+4. Checks Docker, provisions the Redis ACL and PostgreSQL database, runs schema migrations, and optionally creates an administrator.
-# About the competition
-about = ""
+For a pre-generated configuration:
-# Starting time of competition wrt time zone in `16:31:23 UTC: +05:30, 17th February 2021, Wednesday` format
-starting_time = ""
+```bash
+./setup.sh
+$EDITOR "$HOME/.beast/config.toml"
+beast init
+```
-# Ending time of competition wrt time zone in `16:31:23 UTC: +05:30, 17th February 2021, Wednesday` format
-ending_time = ""
+`setup.sh` creates unique JWT/PostgreSQL/Redis secrets and builds Beast. It does not install, start, or reconfigure external services. It never overwrites an existing configuration.
-# Time zone for reference in `Asia/Calcutta: UTC +05:30` format
-timezone = ""
+For production, replace the generated localhost certificate with a certificate issued for the deployed hostname. The certificate may be public; the key must be a non-symlink regular file with mode `0600`.
-# Optional fields
+## Important configuration controls
-# Prizes for the competition winners
-prizes = ""
+Use `_examples/example.config.toml` as the annotated reference.
-# Absolute path of logo file. Default logo dir is in the "BEAST_GLOBAL_DIR/assets/"
-logo_url = ""
-```
+- `config.toml` must be a non-symlink regular file with mode `0600`.
+- `jwt_secret` must be unique and at least 32 bytes.
+- `allowed_origins` is an explicit CORS allowlist. Use HTTPS except for loopback development.
+- Default CPU, memory, PID, and CPU-count values are also maximum per-challenge overrides.
+- Every active worker needs a non-overlapping host `port_range` on that worker.
+- Remote workers require a mode-`0600` SSH key and a trusted `known_hosts` file. Beast never accepts an unknown host key automatically.
+- Active Git remotes accept SSH URLs only and require a mode-`0600` key.
+- Active webhooks must be official HTTPS Slack or Discord webhook URLs.
-Along with this configuration file we also need one more configuration file which is used by beast static content provider
-and protects some routes for the same.
+Remote SSH users need access to Docker on their worker. That permission is root-equivalent; use a dedicated account and key. Beast runs argument-quoted commands and stores remote staging data under the SSH user's `~/.beast` directory.
-The file is located at `$HOME/.beast/.static.beast.htpasswd` and is generated using `htpasswd` utility. To generate this file
-use the below command.
+## Run
```bash
-$ htpasswd -C 10 -c -B .static.beast.htpasswd
-New Password:
+beast run --health-probe
```
-### Configuration Directory Structure
+The default listener is `https://0.0.0.0:5005`. Restrict it with host firewall/security-group rules or a trusted reverse proxy. API documentation is available at `https://:5005/api/docs/index.html`.
-The configuration directory structure of beast(`$HOME/.beast`) look something as below:
+Optional run controls include `--auto-deploy`, `--periodic-sync`, `--no-cache`, `--port`, and `--default-author-password-file`. The password file must be a non-symlink regular file with mode `0600` and contain 12–128 bytes.
-```
-.beast/
-├── assets
-│ └── logo.png
-├── authorized_keys_file
-├── beast.db
-├── config.toml
-├── hack-secrets
-│ ├── id_rsa
-│ └── id_rsa.pub
-├── remote
-│ └── hack-test
-│ └── challenges
-│ └── MIGHTY-PHP
-│ ├── beast.toml
-│ └── challenge
-│ └── flag.php
-├── scripts
-│ └── 043a6aa3658c08c85d64321d986afbf69cb7ad345f16fe8aa0368ee6478f6e24
-├
-├── staging
-│ └── MIGHTY-PHP
-│ ├── beast.toml
-│ ├── Dockerfile
-│ ├── logs
-│ │ └── MIGHTY-PHP.20190609173618.log
-│ ├── MIGHTY-PHP.tar.gz
-│ └── static
-├── uploads
-│ └── MIGHTY-GO
-│ ├── beast.toml
-│ └── challenge
-│ └── flag.go
-```
+## Static assets
-### Configuring frontend for competition hosting
+Build the pinned unprivileged Nginx image with:
-Clone the [frontend repository](https://github.com/sdslabs/beast-frontend) and follow the setup instructions mentioned in its `README.md` file.
+```bash
+make requirements
+```
-## Run
+Deploy it through the authenticated management API. Beast mounts only each challenge's normalized static directory read-only and publishes the container on host port `8034`. Put a TLS reverse proxy in front of that port and configure `beast_static_url` to the public HTTPS origin. No htpasswd file is used.
-Once the setup and configuration is done run the beast web server using the below command
+## Stop and remove local state
```bash
-beast run -v -p
+./scripts/teardown.sh
+./scripts/teardown.sh --purge-data
```
-Follow the API Authentication flow to obtain a Token, use that token further to make any REST API call to the beast server.
-The whole swagger API documentation for the REST API can be found at the `http://localhost:5005/api/docs/index.html`
-
-## Note
+Teardown obtains or verifies the controller lock before signaling a same-user Beast process. Normal shutdown preserves deployed challenges. `--purge-data` removes local `$BEAST_HOME` (default `$HOME/.beast`) only; PostgreSQL and Redis data are not removed.
-- Make sure all the secrets/passwords you are using are strong enough. Also, make sure that the static content provider endpoint
- is HTTPS protected.
+Back up the PostgreSQL database and Redis state before destructive reset/restore commands. Those CLI commands require `--yes`.
diff --git a/docs/Usage.md b/docs/Usage.md
index 86ded92f..e06802ac 100644
--- a/docs/Usage.md
+++ b/docs/Usage.md
@@ -1,55 +1,60 @@
# Usage
-## Installation
-
-Before provisioning beast on your host, make sure you have the following dependencies installed.
-
-* [Docker for Linux](https://docs.docker.com/install/linux/docker-ce/ubuntu/)
-* [GoLang for Linux](https://golang.org/doc/install#tarball)
-* [git](https://git-scm.com/)
-
-You can either build beast from source or download a latest realease binary from Github Relaeases.
+All API traffic uses HTTPS. Examples below trust the development certificate explicitly:
```bash
-$ export GO111MODULES=on
+export BEAST_URL=https://localhost:5005
+export BEAST_CA="$HOME/.beast/secrets/tls.crt"
+```
-$ git clone git@github.com:sdslabs/beastv4.git
+## Authenticate
-$ cd beastv4 && make build
->>> Building Beast
+```bash
+beast getauth --host "$BEAST_URL" --ca-file "$BEAST_CA" --username
```
-This should build beast from source in `$GOPATH/bin/beast`, you can then use this binary to run beast API server. The `-n`
-flag tells beast to not use the authorization middleware.
+Or call the login endpoint and extract the returned token:
```bash
-$ beast run -v -n
+curl --cacert "$BEAST_CA" --request POST "$BEAST_URL/auth/login" \
+ --data-urlencode 'username=' \
+ --data-urlencode 'password='
```
-To interact with beast API server you can look at the swagger API documentation hosted on beast itself. Navigate to http://localhost:5005/api/docs/index.html to get a detail of available endpoints.
+Send `Authorization: Bearer ` on protected routes. Never place passwords or tokens in URLs, shell history, source files, or logs.
-To be able to interact with the REST API interface you should be authorized by beast, go to [Authentication Section](/APIAuth.md) to know about the flow of authentication.
+## CLI challenge operations
-## Examples
+```bash
+beast challenge show --all
+beast challenge deploy
+beast challenge undeploy
+beast challenge redeploy
+beast challenge purge
+```
-Some examples of API action triggers are given below
+Bulk operations accept `--all` or `--tag`. Controller-local deployment accepts `beast challenge deploy --local-directory /absolute/path`; validate it first with `beast verify --local-directory /absolute/path`. The HTTP equivalents for bulk, scheduling, static-service, and controller-local path operations are administrator-only. Authors/maintainers may operate only challenges they own.
-```bash
-# Reloading a configuration change in beast global config
-$ curl -X PATCH localhost:5005/api/config/reload
-{"message":"CONFIG RELOAD SUCCESSFUL"}
+Worker failures are returned by the CLI after queued work completes. A successful enqueue is not reported as a successful deployment.
-# Deploying a challenge named my-challenge using API.
-$ curl -X POST --data "action=deploy&name=my-challenge" localhost:5005/api/manage/challenge/
-{"message":"Deploy for challenge simple-web has been triggered, check stats"}
+## API examples
-# Purging the deployed challenge completely.
-$ curl -X POST --data "action=purge&name=my-challenge" localhost:5005/api/manage/challenge/
-{"message":"Your action purge on challenge simple-web was successful"}
+```bash
+curl --cacert "$BEAST_CA" \
+ --header "Authorization: Bearer $BEAST_TOKEN" \
+ "$BEAST_URL/api/status/all"
+
+curl --cacert "$BEAST_CA" \
+ --header "Authorization: Bearer $BEAST_TOKEN" \
+ --request POST "$BEAST_URL/api/manage/challenge/" \
+ --data-urlencode 'action=deploy' \
+ --data-urlencode 'name=my-challenge'
```
-For more examples and available API routes go to Swagger API documentation.
+Use Swagger at `$BEAST_URL/api/docs/index.html` for the current route and payload contract. Management endpoints require author/maintainer or administrator roles; remote, configuration, user-control, and static-service operations require an administrator.
+
+## Maintenance
-## Note
+Database and cache backup/reset/restore commands initialize the same validated runtime configuration as the server. Reset and restore are destructive and require `--yes`. Backups are explicit operations; health checks and shutdown do not perform synchronous backups.
-* You can even run beast on your local environment and still be able to configure and manage deployments, for this you will need a secure tunnel to reach your docker daemon, which will be used for container lifecycle management on the actual host.
+Controller shutdown stops its workers, scheduler, subscriptions, and datastore connections while preserving deployed workloads. On restart, Beast reconciles runtime state and expired instances.
diff --git a/docs/cmdref/beast.md b/docs/cmdref/beast.md
index aef4f7a7..a8db8350 100644
--- a/docs/cmdref/beast.md
+++ b/docs/cmdref/beast.md
@@ -20,22 +20,28 @@ beast [flags]
```
-h, --help help for beast
- -n, --noauth Skip Authorization
-v, --verbose Print extra information in stdout1
```
### SEE ALSO
+* [beast backup-cache](beast_backup-cache.md) - Backs up the configured Redis database
+* [beast backup-database](beast_backup-database.md) - Backs up the configured PostgreSQL database
+* [beast chall-details](beast_chall-details.md) - Lists all challenge details
* [beast challenge](beast_challenge.md) - Performs action to the challs
* [beast cmdref](beast_cmdref.md) - Generate beast command reference
-* [beast create-author](beast_create-author.md) - Creates new author
-* [beast disable-author-ssh](beast_disable-author-ssh.md) - Disables current authors to ssh into the containers
-* [beast getauth](beast_getauth.md) - Gets Auth token from beast server
+* [beast config](beast_config.md) - Run interactive beast configuration setup
+* [beast create-admin](beast_create-admin.md) - Creates a new admin
+* [beast create-author](beast_create-author.md) - Creates a new author
+* [beast getauth](beast_getauth.md) - Gets an authentication token from the Beast server
* [beast health-probe](beast_health-probe.md) - Run Health Probe
* [beast init](beast_init.md) - Run Beast initial setup bootsetps.
* [beast logs](beast_logs.md) - Provides live logs of a container
+* [beast new](beast_new.md) - Generate a challenge configuration and public directory
+* [beast reset-cache](beast_reset-cache.md) - Backs up and resets the configured Redis database
+* [beast reset-database](beast_reset-database.md) - Backs up and resets the configured PostgreSQL database
+* [beast restore-cache](beast_restore-cache.md) - Restores the configured Redis database from a backup
+* [beast restore-database](beast_restore-database.md) - Restores the configured PostgreSQL database from a backup
* [beast run](beast_run.md) - Run Beast API server
* [beast verify](beast_verify.md) - Verifies challenge config
* [beast version](beast_version.md) - Displays the version of the current build of beast
-
-###### Auto generated by spf13/cobra on 5-Aug-2019
diff --git a/docs/cmdref/beast_backup-cache.md b/docs/cmdref/beast_backup-cache.md
new file mode 100644
index 00000000..19df595a
--- /dev/null
+++ b/docs/cmdref/beast_backup-cache.md
@@ -0,0 +1,27 @@
+## beast backup-cache
+
+Backs up the configured Redis database
+
+### Synopsis
+
+Backs up the configured Redis database
+
+```
+beast backup-cache [flags]
+```
+
+### Options
+
+```
+ -h, --help help for backup-cache
+```
+
+### Options inherited from parent commands
+
+```
+ -v, --verbose Print extra information in stdout1
+```
+
+### SEE ALSO
+
+* [beast](beast.md) - Beast is an deployment and management tool for CTF challenges.
diff --git a/docs/cmdref/beast_backup-database.md b/docs/cmdref/beast_backup-database.md
new file mode 100644
index 00000000..93e7b6c4
--- /dev/null
+++ b/docs/cmdref/beast_backup-database.md
@@ -0,0 +1,27 @@
+## beast backup-database
+
+Backs up the configured PostgreSQL database
+
+### Synopsis
+
+Backs up the configured PostgreSQL database
+
+```
+beast backup-database [flags]
+```
+
+### Options
+
+```
+ -h, --help help for backup-database
+```
+
+### Options inherited from parent commands
+
+```
+ -v, --verbose Print extra information in stdout1
+```
+
+### SEE ALSO
+
+* [beast](beast.md) - Beast is an deployment and management tool for CTF challenges.
diff --git a/docs/cmdref/beast_chall-details.md b/docs/cmdref/beast_chall-details.md
new file mode 100644
index 00000000..42ba4360
--- /dev/null
+++ b/docs/cmdref/beast_chall-details.md
@@ -0,0 +1,29 @@
+## beast chall-details
+
+Lists all challenge details
+
+### Synopsis
+
+Lists all challenge details | Flags available : --status , --tags. Status flag can take arguments : deployed / undeployed / queued. Tags flag can take multiple arguments seperated with ',' : (Ex : --tags=pwn,image,docker). Details are shown for challenges that have specified status and one of the specified tags.
+
+```
+beast chall-details [flags]
+```
+
+### Options
+
+```
+ -h, --help help for chall-details
+ -s, --status string Filter by status : deployed / undeployed / queued (default "all")
+ -t, --tags string Filter by tagname : pwn / web / image / docker
+```
+
+### Options inherited from parent commands
+
+```
+ -v, --verbose Print extra information in stdout1
+```
+
+### SEE ALSO
+
+* [beast](beast.md) - Beast is an deployment and management tool for CTF challenges.
diff --git a/docs/cmdref/beast_challenge.md b/docs/cmdref/beast_challenge.md
index 13841708..d7ac28f3 100644
--- a/docs/cmdref/beast_challenge.md
+++ b/docs/cmdref/beast_challenge.md
@@ -17,18 +17,16 @@ beast challenge action [challname] [-atld] [flags]
-d, --delete-entry Deletes db entry related to this challenge
-h, --help help for challenge
-l, --local-directory string Deploys challenge from local directory
+ -c, --no-cache Build image of challenge without using cache
-t, --tag string Performs action to the tag provided
```
### Options inherited from parent commands
```
- -n, --noauth Skip Authorization
-v, --verbose Print extra information in stdout1
```
### SEE ALSO
* [beast](beast.md) - Beast is an deployment and management tool for CTF challenges.
-
-###### Auto generated by spf13/cobra on 5-Aug-2019
diff --git a/docs/cmdref/beast_cmdref.md b/docs/cmdref/beast_cmdref.md
index 9734ed0c..29c95194 100644
--- a/docs/cmdref/beast_cmdref.md
+++ b/docs/cmdref/beast_cmdref.md
@@ -7,7 +7,7 @@ Generate beast command reference
Generate beast command reference
```
-beast cmdref [-d] [flags]
+beast cmdref [-r] [flags]
```
### Options
@@ -20,12 +20,9 @@ beast cmdref [-d] [flags]
### Options inherited from parent commands
```
- -n, --noauth Skip Authorization
-v, --verbose Print extra information in stdout1
```
### SEE ALSO
* [beast](beast.md) - Beast is an deployment and management tool for CTF challenges.
-
-###### Auto generated by spf13/cobra on 5-Aug-2019
diff --git a/docs/cmdref/beast_config.md b/docs/cmdref/beast_config.md
new file mode 100644
index 00000000..bdc60a50
--- /dev/null
+++ b/docs/cmdref/beast_config.md
@@ -0,0 +1,27 @@
+## beast config
+
+Run interactive beast configuration setup
+
+### Synopsis
+
+Creates the global Beast config file while prompting the user interactively whenever needed.
+
+```
+beast config [flags]
+```
+
+### Options
+
+```
+ -h, --help help for config
+```
+
+### Options inherited from parent commands
+
+```
+ -v, --verbose Print extra information in stdout1
+```
+
+### SEE ALSO
+
+* [beast](beast.md) - Beast is an deployment and management tool for CTF challenges.
diff --git a/docs/cmdref/beast_create-admin.md b/docs/cmdref/beast_create-admin.md
new file mode 100644
index 00000000..193fe794
--- /dev/null
+++ b/docs/cmdref/beast_create-admin.md
@@ -0,0 +1,30 @@
+## beast create-admin
+
+Creates a new admin
+
+### Synopsis
+
+Creates a new admin
+
+```
+beast create-admin [flags]
+```
+
+### Options
+
+```
+ --email string Email of the new admin
+ -h, --help help for create-admin
+ --name string Name of the new admin
+ --username string Username of the new admin
+```
+
+### Options inherited from parent commands
+
+```
+ -v, --verbose Print extra information in stdout1
+```
+
+### SEE ALSO
+
+* [beast](beast.md) - Beast is an deployment and management tool for CTF challenges.
diff --git a/docs/cmdref/beast_create-author.md b/docs/cmdref/beast_create-author.md
index ad9000d9..4acaaf5d 100644
--- a/docs/cmdref/beast_create-author.md
+++ b/docs/cmdref/beast_create-author.md
@@ -1,10 +1,10 @@
## beast create-author
-Creates new author
+Creates a new author
### Synopsis
-Creates new author using command line arguments
+Creates a new author
```
beast create-author [flags]
@@ -13,21 +13,18 @@ beast create-author [flags]
### Options
```
- --email string Email of the new author
- -h, --help help for create-author
- --name string Name of the new author
- --publickey string Public key file representing new author
+ --email string Email of the new author
+ -h, --help help for create-author
+ --name string Name of the new author
+ --username string Username of the new author
```
### Options inherited from parent commands
```
- -n, --noauth Skip Authorization
-v, --verbose Print extra information in stdout1
```
### SEE ALSO
* [beast](beast.md) - Beast is an deployment and management tool for CTF challenges.
-
-###### Auto generated by spf13/cobra on 5-Aug-2019
diff --git a/docs/cmdref/beast_disable-author-ssh.md b/docs/cmdref/beast_disable-author-ssh.md
deleted file mode 100644
index a9a3b1b9..00000000
--- a/docs/cmdref/beast_disable-author-ssh.md
+++ /dev/null
@@ -1,30 +0,0 @@
-## beast disable-author-ssh
-
-Disables current authors to ssh into the containers
-
-### Synopsis
-
-Disables current authors to ssh into the containers
-
-```
-beast disable-author-ssh [flags]
-```
-
-### Options
-
-```
- -h, --help help for disable-author-ssh
-```
-
-### Options inherited from parent commands
-
-```
- -n, --noauth Skip Authorization
- -v, --verbose Print extra information in stdout1
-```
-
-### SEE ALSO
-
-* [beast](beast.md) - Beast is an deployment and management tool for CTF challenges.
-
-###### Auto generated by spf13/cobra on 5-Aug-2019
diff --git a/docs/cmdref/beast_getauth.md b/docs/cmdref/beast_getauth.md
index 516fffb5..ca16c47a 100644
--- a/docs/cmdref/beast_getauth.md
+++ b/docs/cmdref/beast_getauth.md
@@ -1,10 +1,10 @@
## beast getauth
-Gets Auth token from beast server
+Gets an authentication token from the Beast server
### Synopsis
-Gets Auth Token from the beast server by completing the challenge from the server
+Gets an authentication token from the Beast server
```
beast getauth [flags]
@@ -13,21 +13,18 @@ beast getauth [flags]
### Options
```
+ --ca-file string CA certificate used to verify the Beast server
-h, --help help for getauth
- -H, --host string Hostname or IP along with port where beast is hosted (default "http://localhost:5005/")
- -i, --identity string Private File location
+ -H, --host string HTTPS URL where Beast is hosted (default "https://localhost:5005/")
-u, --username string Username
```
### Options inherited from parent commands
```
- -n, --noauth Skip Authorization
-v, --verbose Print extra information in stdout1
```
### SEE ALSO
* [beast](beast.md) - Beast is an deployment and management tool for CTF challenges.
-
-###### Auto generated by spf13/cobra on 5-Aug-2019
diff --git a/docs/cmdref/beast_health-probe.md b/docs/cmdref/beast_health-probe.md
index e0997165..18c484b3 100644
--- a/docs/cmdref/beast_health-probe.md
+++ b/docs/cmdref/beast_health-probe.md
@@ -19,12 +19,9 @@ beast health-probe [flags]
### Options inherited from parent commands
```
- -n, --noauth Skip Authorization
-v, --verbose Print extra information in stdout1
```
### SEE ALSO
* [beast](beast.md) - Beast is an deployment and management tool for CTF challenges.
-
-###### Auto generated by spf13/cobra on 5-Aug-2019
diff --git a/docs/cmdref/beast_init.md b/docs/cmdref/beast_init.md
index 8f34d84f..1ebd5f96 100644
--- a/docs/cmdref/beast_init.md
+++ b/docs/cmdref/beast_init.md
@@ -4,7 +4,7 @@ Run Beast initial setup bootsetps.
### Synopsis
-Initializes beast by setting up beast directory, checking for permission. It also configures the logger and local SQLite database to be used by beast
+Initializes Beast directories, configuration, TLS, Redis ACLs, PostgreSQL schema, and an optional administrator account.
```
beast init [flags]
@@ -19,12 +19,9 @@ beast init [flags]
### Options inherited from parent commands
```
- -n, --noauth Skip Authorization
-v, --verbose Print extra information in stdout1
```
### SEE ALSO
* [beast](beast.md) - Beast is an deployment and management tool for CTF challenges.
-
-###### Auto generated by spf13/cobra on 5-Aug-2019
diff --git a/docs/cmdref/beast_logs.md b/docs/cmdref/beast_logs.md
index e1ad0beb..4375751f 100644
--- a/docs/cmdref/beast_logs.md
+++ b/docs/cmdref/beast_logs.md
@@ -19,12 +19,9 @@ beast logs CHALLNAME [flags]
### Options inherited from parent commands
```
- -n, --noauth Skip Authorization
-v, --verbose Print extra information in stdout1
```
### SEE ALSO
* [beast](beast.md) - Beast is an deployment and management tool for CTF challenges.
-
-###### Auto generated by spf13/cobra on 5-Aug-2019
diff --git a/docs/cmdref/beast_new.md b/docs/cmdref/beast_new.md
new file mode 100644
index 00000000..a3bf8c9a
--- /dev/null
+++ b/docs/cmdref/beast_new.md
@@ -0,0 +1,27 @@
+## beast new
+
+Generate a challenge configuration and public directory
+
+### Synopsis
+
+Generate a challenge configuration and public directory
+
+```
+beast new [flags]
+```
+
+### Options
+
+```
+ -h, --help help for new
+```
+
+### Options inherited from parent commands
+
+```
+ -v, --verbose Print extra information in stdout1
+```
+
+### SEE ALSO
+
+* [beast](beast.md) - Beast is an deployment and management tool for CTF challenges.
diff --git a/docs/cmdref/beast_reset-cache.md b/docs/cmdref/beast_reset-cache.md
new file mode 100644
index 00000000..accd4016
--- /dev/null
+++ b/docs/cmdref/beast_reset-cache.md
@@ -0,0 +1,28 @@
+## beast reset-cache
+
+Backs up and resets the configured Redis database
+
+### Synopsis
+
+Backs up and resets the configured Redis database
+
+```
+beast reset-cache [flags]
+```
+
+### Options
+
+```
+ -h, --help help for reset-cache
+ --yes Confirm destructive cache reset
+```
+
+### Options inherited from parent commands
+
+```
+ -v, --verbose Print extra information in stdout1
+```
+
+### SEE ALSO
+
+* [beast](beast.md) - Beast is an deployment and management tool for CTF challenges.
diff --git a/docs/cmdref/beast_reset-database.md b/docs/cmdref/beast_reset-database.md
new file mode 100644
index 00000000..e80c92dd
--- /dev/null
+++ b/docs/cmdref/beast_reset-database.md
@@ -0,0 +1,28 @@
+## beast reset-database
+
+Backs up and resets the configured PostgreSQL database
+
+### Synopsis
+
+Backs up and resets the configured PostgreSQL database
+
+```
+beast reset-database [flags]
+```
+
+### Options
+
+```
+ -h, --help help for reset-database
+ --yes Confirm destructive database reset
+```
+
+### Options inherited from parent commands
+
+```
+ -v, --verbose Print extra information in stdout1
+```
+
+### SEE ALSO
+
+* [beast](beast.md) - Beast is an deployment and management tool for CTF challenges.
diff --git a/docs/cmdref/beast_restore-cache.md b/docs/cmdref/beast_restore-cache.md
new file mode 100644
index 00000000..e111dbb0
--- /dev/null
+++ b/docs/cmdref/beast_restore-cache.md
@@ -0,0 +1,29 @@
+## beast restore-cache
+
+Restores the configured Redis database from a backup
+
+### Synopsis
+
+Restores the configured Redis database from a backup
+
+```
+beast restore-cache [flags]
+```
+
+### Options
+
+```
+ -h, --help help for restore-cache
+ -r, --restore-file string Restore file to be used for restoration.
+ --yes Confirm destructive cache restore
+```
+
+### Options inherited from parent commands
+
+```
+ -v, --verbose Print extra information in stdout1
+```
+
+### SEE ALSO
+
+* [beast](beast.md) - Beast is an deployment and management tool for CTF challenges.
diff --git a/docs/cmdref/beast_restore-database.md b/docs/cmdref/beast_restore-database.md
new file mode 100644
index 00000000..4f3238ca
--- /dev/null
+++ b/docs/cmdref/beast_restore-database.md
@@ -0,0 +1,29 @@
+## beast restore-database
+
+Restores the configured PostgreSQL database from a backup
+
+### Synopsis
+
+Restores the configured PostgreSQL database from a backup
+
+```
+beast restore-database [flags]
+```
+
+### Options
+
+```
+ -h, --help help for restore-database
+ -r, --restore-file string Backup file to be used for restoration.
+ --yes Confirm destructive database restore
+```
+
+### Options inherited from parent commands
+
+```
+ -v, --verbose Print extra information in stdout1
+```
+
+### SEE ALSO
+
+* [beast](beast.md) - Beast is an deployment and management tool for CTF challenges.
diff --git a/docs/cmdref/beast_run.md b/docs/cmdref/beast_run.md
index 7003c45e..bc98a4ef 100644
--- a/docs/cmdref/beast_run.md
+++ b/docs/cmdref/beast_run.md
@@ -13,21 +13,21 @@ beast run [flags]
### Options
```
- -k, --health-probe Run health check service for beast deployed challenges
- -h, --help help for run
- -s, --periodic-sync Periodically sync remote with beast.
- -p, --port string Port to run the beast server on.
+ -a, --auto-deploy Auto deploy all challenges from remote on server start.
+ --default-author-password-file string 0600 file containing the password used to create missing authors
+ -k, --health-probe Run health check service for beast deployed challenges
+ -h, --help help for run
+ -c, --no-cache Build image of challenge without using cache
+ -s, --periodic-sync Periodically sync remote with beast and auto update challenges.
+ -p, --port string Port to run the beast server on.
```
### Options inherited from parent commands
```
- -n, --noauth Skip Authorization
-v, --verbose Print extra information in stdout1
```
### SEE ALSO
* [beast](beast.md) - Beast is an deployment and management tool for CTF challenges.
-
-###### Auto generated by spf13/cobra on 5-Aug-2019
diff --git a/docs/cmdref/beast_verify.md b/docs/cmdref/beast_verify.md
index 5feec9ac..82529833 100644
--- a/docs/cmdref/beast_verify.md
+++ b/docs/cmdref/beast_verify.md
@@ -7,24 +7,22 @@ Verifies challenge config
Verifies challenge config
```
-beast verify challenge-name [flags]
+beast verify [challenge-name] [flags]
```
### Options
```
- -h, --help help for verify
+ -h, --help help for verify
+ -l, --local-directory string Validate a challenge from a local directory
```
### Options inherited from parent commands
```
- -n, --noauth Skip Authorization
-v, --verbose Print extra information in stdout1
```
### SEE ALSO
* [beast](beast.md) - Beast is an deployment and management tool for CTF challenges.
-
-###### Auto generated by spf13/cobra on 5-Aug-2019
diff --git a/docs/cmdref/beast_version.md b/docs/cmdref/beast_version.md
index e5047b30..1607aa84 100644
--- a/docs/cmdref/beast_version.md
+++ b/docs/cmdref/beast_version.md
@@ -20,12 +20,9 @@ beast version [flags]
### Options inherited from parent commands
```
- -n, --noauth Skip Authorization
-v, --verbose Print extra information in stdout1
```
### SEE ALSO
* [beast](beast.md) - Beast is an deployment and management tool for CTF challenges.
-
-###### Auto generated by spf13/cobra on 5-Aug-2019
diff --git a/extras/static-content/Dockerfile b/extras/static-content/Dockerfile
index c2905989..b4c00469 100644
--- a/extras/static-content/Dockerfile
+++ b/extras/static-content/Dockerfile
@@ -1,27 +1,8 @@
-FROM nginx:latest
+FROM nginxinc/nginx-unprivileged:1.28.0-alpine@sha256:c97ff0bf7cbae369953c6da1232ec14ad9f971d66360c5698db0856a4cd657a0
-LABEL version="0.1"
-LABEL author="fristonio"
+USER root
+RUN mkdir -p /beast && chown 101:101 /beast
+COPY --chown=101:101 beast.conf /etc/nginx/conf.d/default.conf
-RUN apt-get update \
- && apt-get install -y -q --no-install-recommends ca-certificates less \
- && apt-get clean \
- && rm -r /var/lib/apt/lists/*
-
-RUN sed -i 's/^http {/&\n server_names_hash_bucket_size 128;/g' /etc/nginx/nginx.conf
-RUN chown nginx:nginx /var/log/nginx/
-
-COPY beast.conf /etc/nginx/conf.d/default.conf
-ADD docker-entry.sh /docker-entry.sh
-RUN chmod +x docker-entry.sh
-
-VOLUME ["/beast"]
-EXPOSE 80
-
-# Add tini
-ENV TINI_VERSION v0.18.0
-ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini /tini
-RUN chmod +x /tini
-ENTRYPOINT ["/tini", "--"]
-
-CMD ["/docker-entry.sh"]
+USER 101
+EXPOSE 8080
diff --git a/extras/static-content/README.md b/extras/static-content/README.md
index 78842a8c..55bdf782 100644
--- a/extras/static-content/README.md
+++ b/extras/static-content/README.md
@@ -10,20 +10,11 @@ Build the docker image using
$ docker build . --tag beast-static:latest
```
-To run the nginx powered static content serving container on port 8034 (by standard) for beast, run
+Beast deploys the image on host port 8034 through the authenticated management API. It mounts only each challenge's public static directory read-only; do not mount the complete Beast staging directory.
```bash
-$ export BEAST_STATIC_PORT=8034
-$ docker run -d -p $BEAST_STATIC_PORT:80 -v :/beast -v :/.static.beast.htpasswd beast-static
-```
-
-For authentication purposes you should create a htpasswd file using apache2-utils. First install apache2-utils and then create a htpasswd file
-
-```bash
-$ sudo apt-get install -y apache2-utils
-
-$ htpasswd -c .static.beast.htpasswd
-
-
-$ mv .static.beast.htpasswd ~/.beast/
+$ make requirements
+$ beast run
+$ curl --cacert -H 'Authorization: Bearer ' \
+ -X POST https://localhost:5005/api/manage/static/deploy
```
diff --git a/extras/static-content/beast.conf b/extras/static-content/beast.conf
index b2e2da04..382c3790 100644
--- a/extras/static-content/beast.conf
+++ b/extras/static-content/beast.conf
@@ -1,46 +1,19 @@
-# Make sure you have static.beast.sdslabs.co in your /etc/hosts file
-# for local deployment and testing.
-
server {
- listen [::]:80;
- listen 80;
-
- server_name static.beast.sdslabs.co;
-
- access_log /var/log/nginx/static.beast.access.log;
- error_log /var/log/nginx/static.beast.error.log;
-
- root /beast/;
-
- location ~ /static/(?.+)/static/(?.+)$ {
- alias /beast/$chall/static/;
-
- try_files $file $file/;
+ listen [::]:8080;
+ listen 8080;
+ server_name _;
+
+ access_log /dev/stdout;
+ error_log /dev/stderr warn;
+ server_tokens off;
+
+ location ~ "^/static/(?[a-z0-9][a-z0-9_.-]{0,127})/static/(?[A-Za-z0-9_-][A-Za-z0-9._-]*(?:/[A-Za-z0-9_-][A-Za-z0-9._-]*)*)$" {
+ alias /beast/$chall/static/$asset;
+ disable_symlinks on;
+ add_header X-Content-Type-Options nosniff always;
}
-}
-
-# Make sure you have static.staging.beast.sdslabs.co in your /etc/hosts file
-# for local deployment and testing.
-
-server {
- listen [::]:80;
- listen 80;
-
- server_name static.staging.beast.sdslabs.co;
-
- access_log /var/log/nginx/static.staging.beast.access.log;
- error_log /var/log/nginx/static.staging.beast.error.log;
-
- auth_basic "Administrator Area.";
- auth_basic_user_file /.static.beast.htpasswd;
-
- root /beast/;
- autoindex on;
- # Restrict access to files like beast.toml, challenge.tar.gz so we
- # don't leak any sensitive information from here. At some point we will
- # make the tar file accessible.
- location ~ \.(toml|conf|json|env|tar.gz)$ {
- return 403;
+ location / {
+ return 404;
}
}
diff --git a/extras/static-content/docker-entry.sh b/extras/static-content/docker-entry.sh
deleted file mode 100644
index ef423eaa..00000000
--- a/extras/static-content/docker-entry.sh
+++ /dev/null
@@ -1,4 +0,0 @@
-#!/bin/bash
-
-/etc/init.d/nginx start
-exec tail -f /var/log/nginx/*
diff --git a/go.mod b/go.mod
index 21e0d0cd..b09459d4 100644
--- a/go.mod
+++ b/go.mod
@@ -6,12 +6,12 @@ require (
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de
github.com/charmbracelet/bubbles v0.21.0
github.com/charmbracelet/bubbletea v1.3.4
- github.com/dgrijalva/jwt-go v3.2.0+incompatible
github.com/docker/docker v20.10.22+incompatible
github.com/docker/go-connections v0.4.0
github.com/gin-contrib/cors v1.3.1
github.com/gin-contrib/static v0.0.0-20200916080430-d45d9a37d28e
github.com/gin-gonic/gin v1.7.0
+ github.com/golang-jwt/jwt/v4 v4.5.2
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.5.5
github.com/jinzhu/gorm v1.9.1
@@ -28,6 +28,7 @@ require (
golang.org/x/net v0.31.0
golang.org/x/term v0.26.0
gopkg.in/src-d/go-git.v4 v4.7.0
+ gopkg.in/yaml.v2 v2.4.0
gorm.io/driver/postgres v1.5.11
gorm.io/gorm v1.25.10
)
@@ -114,7 +115,6 @@ require (
gopkg.in/src-d/go-billy.v4 v4.3.0 // indirect
gopkg.in/src-d/go-git-fixtures.v3 v3.3.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
- gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gotest.tools/v3 v3.4.0 // indirect
)
diff --git a/go.sum b/go.sum
index cf05334d..d29f8a3c 100644
--- a/go.sum
+++ b/go.sum
@@ -51,8 +51,6 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/denisenkom/go-mssqldb v0.0.0-20180901172138-1eb28afdf9b6 h1:BZGp1dbKFjqlGmxEpwkDpCWNxVwEYnUPoncIzLiHlPo=
github.com/denisenkom/go-mssqldb v0.0.0-20180901172138-1eb28afdf9b6/go.mod h1:xN/JuLBIz4bjkxNmByTiV1IbhfnYb6oo99phBn4Eqhc=
-github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
-github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/docker/distribution v2.8.1+incompatible h1:Q50tZOPR6T/hjNsyc9g8/syEs6bk8XXApsHjKukMl68=
@@ -105,6 +103,8 @@ github.com/go-sql-driver/mysql v1.4.0 h1:7LxgVwFb2hIQtMm87NdgAVfXjnt4OePseqT1tKx
github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
+github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
+github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3 h1:gyjaxf+svBWX08ZjK86iN9geUJF0H6gp2IRKX6Nf6/I=
diff --git a/mkdocs.yml b/mkdocs.yml
index 0b0b5227..01db8eaf 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -2,11 +2,18 @@ site_name: Beast
nav:
- Home: README.md
- - Usage: Usage.md
- Setup: Setup.md
- - Documentation: Documentation.md
- - Samples: SampleChallenges.md
- - APIDocs: /apiDocs
- - CmdRef: cmdref/beast.md
+ - Getting started: GettingStarted.md
+ - Usage: Usage.md
+ - Authentication: APIAuth.md
+ - Challenges:
+ - Configuration: ChallConfig.md
+ - Types: ChallTypes.md
+ - Samples: SampleChallenges.md
+ - Architecture: Architecture.md
+ - Features: Features.md
+ - Deployment: Deployment.md
+ - Contributing: Contribution.md
+ - Command reference: cmdref/beast.md
theme: mkdocs
diff --git a/pkg/auth/auth.go b/pkg/auth/auth.go
index 313b4b80..ef58fbad 100644
--- a/pkg/auth/auth.go
+++ b/pkg/auth/auth.go
@@ -1,10 +1,12 @@
package auth
import (
- "bytes"
"crypto/rand"
"crypto/sha256"
+ "crypto/subtle"
"errors"
+ "fmt"
+ "io"
_ "github.com/jinzhu/gorm/dialects/sqlite"
"golang.org/x/crypto/pbkdf2"
@@ -25,10 +27,11 @@ type AuthModel struct {
Salt []byte
}
-func CreateModel(username, password, role string) AuthModel {
-
+func CreateModel(username, password, role string) (AuthModel, error) {
salt := make([]byte, 16)
- rand.Read(salt)
+ if _, err := io.ReadFull(rand.Reader, salt); err != nil {
+ return AuthModel{}, fmt.Errorf("generate password salt: %w", err)
+ }
auth1 := AuthModel{
Username: username,
@@ -36,12 +39,12 @@ func CreateModel(username, password, role string) AuthModel {
Salt: salt,
Role: role,
}
- return auth1
+ return auth1, nil
}
func Authenticate(username, password string, authEntry AuthModel) (string, error) {
hashedPassword := pbkdf2.Key([]byte(password), authEntry.Salt, ITERATIONS, HASH_LENGTH, sha256.New)
- if !bytes.Equal(hashedPassword, authEntry.Password) {
+ if subtle.ConstantTimeCompare(hashedPassword, authEntry.Password) != 1 {
return "", errors.New("The username or password is invalid")
}
diff --git a/pkg/auth/auth_test.go b/pkg/auth/auth_test.go
new file mode 100644
index 00000000..bdcda406
--- /dev/null
+++ b/pkg/auth/auth_test.go
@@ -0,0 +1,21 @@
+package auth
+
+import (
+ "bytes"
+ "testing"
+)
+
+func TestCreateModelUsesUniqueSalt(t *testing.T) {
+ Init(100, 32, 60, "issuer", "secret", nil, nil, nil)
+ first, err := CreateModel("alice", "password", "user")
+ if err != nil {
+ t.Fatal(err)
+ }
+ second, err := CreateModel("alice", "password", "user")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if bytes.Equal(first.Salt, second.Salt) || bytes.Equal(first.Password, second.Password) {
+ t.Fatal("password models reused salt-derived data")
+ }
+}
diff --git a/pkg/auth/token.go b/pkg/auth/token.go
index 6f087249..09f3245d 100644
--- a/pkg/auth/token.go
+++ b/pkg/auth/token.go
@@ -4,17 +4,21 @@ import (
"fmt"
"time"
- jwt "github.com/dgrijalva/jwt-go"
+ jwt "github.com/golang-jwt/jwt/v4"
)
type CustomClaims struct {
- User string `json:"usr"`
- Role string `json:"eml"`
- ExpiresAt int64 `json:"exp"`
- IssuedAt int64 `json:"iat"`
- Issuer string `json:"iss"`
+ User string `json:"usr"`
+ Role string `json:"role"`
+ TokenUse string `json:"token_use"`
+ jwt.RegisteredClaims
}
+const (
+ AccessTokenUse = "access"
+ PasswordResetTokenUse = "password_reset"
+)
+
const (
ADMIN int = 1 << 0
MANAGER int = 1 << 1
@@ -27,54 +31,95 @@ var (
UserRoles []string
)
-func (c CustomClaims) Valid() error {
- if c.ExpiresAt < time.Now().Unix() {
- return fmt.Errorf("Token Expired")
+func AuthorizeClaims(jwtTokenString string, roleAccess int) (*CustomClaims, error) {
+ claims, err := parseClaims(jwtTokenString)
+ if err != nil {
+ return nil, err
+ }
+ if claims.TokenUse != AccessTokenUse {
+ return nil, fmt.Errorf("token is not an access token")
}
- return nil
+ if !((roleAccess&MANAGER) != 0 && contains(ManagerRoles, claims.Role) ||
+ (roleAccess&ADMIN) != 0 && contains(AdminRoles, claims.Role) ||
+ (roleAccess&USER) != 0 && contains(UserRoles, claims.Role)) {
+ return nil, fmt.Errorf("role access error")
+ }
+
+ return claims, nil
}
-func Authorize(jwtTokenString string, roleAccess int) error {
+func AuthorizePasswordResetClaims(jwtTokenString string) (*CustomClaims, error) {
+ claims, err := parseClaims(jwtTokenString)
+ if err != nil {
+ return nil, err
+ }
+ if claims.TokenUse != PasswordResetTokenUse {
+ return nil, fmt.Errorf("token is not a password reset token")
+ }
+ return claims, nil
+}
+
+func parseClaims(jwtTokenString string) (*CustomClaims, error) {
token, err := jwt.ParseWithClaims(jwtTokenString, &CustomClaims{}, func(token *jwt.Token) (interface{}, error) {
- if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
- return nil, fmt.Errorf("Token invalid")
+ if token.Method != jwt.SigningMethodHS256 {
+ return nil, fmt.Errorf("token signing method is invalid")
}
return []byte(JWTSECRET), nil
- })
+ }, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}))
if err != nil {
- return err
+ return nil, err
}
claims, ok := token.Claims.(*CustomClaims)
if !ok || !token.Valid {
- return fmt.Errorf("Token invalid")
+ return nil, fmt.Errorf("token is invalid")
}
-
- if !((roleAccess&MANAGER) != 0 && contains(ManagerRoles, claims.Role) ||
- (roleAccess&ADMIN) != 0 && contains(AdminRoles, claims.Role) ||
- (roleAccess&USER) != 0 && contains(UserRoles, claims.Role)) {
- return fmt.Errorf("Role Access Error")
+ if claims.Issuer != ISSUER {
+ return nil, fmt.Errorf("token issuer is invalid")
}
- return token.Claims.Valid()
+ return claims, nil
+}
+
+func Authorize(jwtTokenString string, roleAccess int) error {
+ _, err := AuthorizeClaims(jwtTokenString, roleAccess)
+ return err
}
func GenerateJWT(authEntry AuthModel) (string, error) {
- t := time.Now().Unix()
+ now := time.Now()
token := jwt.NewWithClaims(jwt.SigningMethodHS256, CustomClaims{
- User: authEntry.Username,
- Role: authEntry.Role,
- ExpiresAt: t + TIME_PERIOD,
- IssuedAt: t,
- Issuer: ISSUER,
+ User: authEntry.Username,
+ Role: authEntry.Role,
+ TokenUse: AccessTokenUse,
+ RegisteredClaims: jwt.RegisteredClaims{
+ ExpiresAt: jwt.NewNumericDate(now.Add(time.Duration(TIME_PERIOD) * time.Second)),
+ IssuedAt: jwt.NewNumericDate(now),
+ Issuer: ISSUER,
+ },
})
return token.SignedString([]byte(JWTSECRET))
}
+func GeneratePasswordResetJWT(username, role string, lifetime time.Duration) (string, error) {
+ now := time.Now()
+ token := jwt.NewWithClaims(jwt.SigningMethodHS256, CustomClaims{
+ User: username,
+ Role: role,
+ TokenUse: PasswordResetTokenUse,
+ RegisteredClaims: jwt.RegisteredClaims{
+ ExpiresAt: jwt.NewNumericDate(now.Add(lifetime)),
+ IssuedAt: jwt.NewNumericDate(now),
+ Issuer: ISSUER,
+ },
+ })
+ return token.SignedString([]byte(JWTSECRET))
+}
+
func contains(a []string, x string) bool {
for _, n := range a {
if x == n {
diff --git a/pkg/auth/token_test.go b/pkg/auth/token_test.go
new file mode 100644
index 00000000..78b08e7c
--- /dev/null
+++ b/pkg/auth/token_test.go
@@ -0,0 +1,102 @@
+package auth
+
+import (
+ "testing"
+ "time"
+
+ jwt "github.com/golang-jwt/jwt/v4"
+)
+
+func initializeTokenTest() {
+ Init(1, 32, 60, "test-issuer", "test-secret", []string{"author"}, []string{"admin"}, []string{"user"})
+}
+
+func TestAuthorizeClaimsValidatesToken(t *testing.T) {
+ initializeTokenTest()
+ token, err := GenerateJWT(AuthModel{Username: "alice", Role: "admin"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ claims, err := AuthorizeClaims(token, ADMIN)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if claims.User != "alice" {
+ t.Fatalf("unexpected user %q", claims.User)
+ }
+}
+
+func TestAuthorizeClaimsRejectsOtherHMACMethods(t *testing.T) {
+ initializeTokenTest()
+ token := jwt.NewWithClaims(jwt.SigningMethodHS384, CustomClaims{
+ User: "alice",
+ Role: "admin",
+ RegisteredClaims: jwt.RegisteredClaims{
+ ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Minute)),
+ Issuer: ISSUER,
+ },
+ })
+ signed, err := token.SignedString([]byte(JWTSECRET))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := AuthorizeClaims(signed, ADMIN); err == nil {
+ t.Fatal("expected signing method rejection")
+ }
+}
+
+func TestAuthorizeClaimsRejectsWrongIssuer(t *testing.T) {
+ initializeTokenTest()
+ token := jwt.NewWithClaims(jwt.SigningMethodHS256, CustomClaims{
+ User: "alice",
+ Role: "admin",
+ RegisteredClaims: jwt.RegisteredClaims{
+ ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Minute)),
+ Issuer: "attacker",
+ },
+ })
+ signed, err := token.SignedString([]byte(JWTSECRET))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := AuthorizeClaims(signed, ADMIN); err == nil {
+ t.Fatal("expected issuer rejection")
+ }
+}
+
+func TestAuthorizeClaimsRejectsExpiredToken(t *testing.T) {
+ initializeTokenTest()
+ token := jwt.NewWithClaims(jwt.SigningMethodHS256, CustomClaims{
+ User: "alice",
+ Role: "admin",
+ RegisteredClaims: jwt.RegisteredClaims{
+ ExpiresAt: jwt.NewNumericDate(time.Now().Add(-time.Minute)),
+ Issuer: ISSUER,
+ },
+ })
+ signed, err := token.SignedString([]byte(JWTSECRET))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := AuthorizeClaims(signed, ADMIN); err == nil {
+ t.Fatal("expected expiration rejection")
+ }
+}
+
+func TestPasswordResetTokenCannotAuthorizeAPIRequests(t *testing.T) {
+ initializeTokenTest()
+ token, err := GeneratePasswordResetJWT("alice", "admin", time.Minute)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := AuthorizeClaims(token, ADMIN); err == nil {
+ t.Fatal("password reset token authorized an API request")
+ }
+ claims, err := AuthorizePasswordResetClaims(token)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if claims.User != "alice" {
+ t.Fatalf("unexpected reset subject %q", claims.User)
+ }
+}
diff --git a/pkg/cr/build_output_test.go b/pkg/cr/build_output_test.go
new file mode 100644
index 00000000..31017a3d
--- /dev/null
+++ b/pkg/cr/build_output_test.go
@@ -0,0 +1,18 @@
+package cr
+
+import (
+ "errors"
+ "testing"
+)
+
+func TestBoundedBuildOutputStopsAtLimit(t *testing.T) {
+ output := &boundedBuildOutput{}
+ data := make([]byte, maxBuildOutput+1)
+ written, err := output.Write(data)
+ if written != maxBuildOutput || !errors.Is(err, errBuildOutputLimit) {
+ t.Fatalf("write = %d, %v", written, err)
+ }
+ if !output.exceeded || output.Buffer().Len() != maxBuildOutput {
+ t.Fatalf("output limit was not recorded")
+ }
+}
diff --git a/pkg/cr/client.go b/pkg/cr/client.go
index 61368402..740ed2d3 100644
--- a/pkg/cr/client.go
+++ b/pkg/cr/client.go
@@ -1,6 +1,15 @@
package cr
-import "github.com/docker/docker/client"
+import (
+ "time"
+
+ "github.com/docker/docker/client"
+)
+
+const (
+ dockerAPIRequestTimeout = 30 * time.Second
+ dockerAPILongTimeout = 2 * time.Minute
+)
func newDockerClient() (*client.Client, error) {
return client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
diff --git a/pkg/cr/container_logs_test.go b/pkg/cr/container_logs_test.go
new file mode 100644
index 00000000..6e9685cc
--- /dev/null
+++ b/pkg/cr/container_logs_test.go
@@ -0,0 +1,22 @@
+package cr
+
+import (
+ "errors"
+ "strings"
+ "testing"
+)
+
+func TestReadContainerLogsEnforcesLimit(t *testing.T) {
+ logs, err := readContainerLogs(strings.NewReader("test"), 4)
+ if err != nil {
+ t.Fatalf("read logs at limit: %v", err)
+ }
+ if string(logs) != "test" {
+ t.Fatalf("unexpected logs: %q", logs)
+ }
+
+ _, err = readContainerLogs(strings.NewReader("tests"), 4)
+ if !errors.Is(err, errContainerLogLimit) {
+ t.Fatalf("expected log limit error, got %v", err)
+ }
+}
diff --git a/pkg/cr/container_security_test.go b/pkg/cr/container_security_test.go
new file mode 100644
index 00000000..c75511be
--- /dev/null
+++ b/pkg/cr/container_security_test.go
@@ -0,0 +1,22 @@
+package cr
+
+import (
+ "testing"
+
+ "github.com/docker/docker/api/types/container"
+)
+
+func TestDefaultContainerSecurity(t *testing.T) {
+ hostConfig := &container.HostConfig{}
+ applyDefaultContainerSecurity(hostConfig)
+ if len(hostConfig.CapDrop) != 1 || hostConfig.CapDrop[0] != "ALL" {
+ t.Fatalf("unexpected dropped capabilities: %v", hostConfig.CapDrop)
+ }
+ if len(hostConfig.SecurityOpt) != 1 || hostConfig.SecurityOpt[0] != "no-new-privileges" {
+ t.Fatalf("unexpected security options: %v", hostConfig.SecurityOpt)
+ }
+ mounts := readOnlyBindMounts(map[string]string{"/host": "/container"})
+ if len(mounts) != 1 || !mounts[0].ReadOnly {
+ t.Fatalf("bind mount was not read-only: %+v", mounts)
+ }
+}
diff --git a/pkg/cr/containers.go b/pkg/cr/containers.go
index a36e9cb3..2011eca6 100644
--- a/pkg/cr/containers.go
+++ b/pkg/cr/containers.go
@@ -1,12 +1,12 @@
package cr
import (
- "bytes"
+ "context"
"encoding/json"
+ "errors"
"fmt"
- "io/ioutil"
+ "io"
"os"
- "os/exec"
"path/filepath"
"strconv"
"strings"
@@ -20,7 +20,6 @@ import (
utils "github.com/sdslabs/beastv4/utils"
log "github.com/sirupsen/logrus"
- "golang.org/x/net/context"
)
type PortMapping struct {
@@ -42,8 +41,12 @@ const (
UDPTraffic TrafficType = "udp"
DefaultTraffic TrafficType = TCPTraffic
+
+ maxContainerLogBytes int64 = 4 << 20
)
+var errContainerLogLimit = errors.New("container logs exceed 4 MiB limit")
+
func IsValidTrafficType(t string) bool {
switch TrafficType(t) {
case TCPTraffic, UDPTraffic:
@@ -93,13 +96,16 @@ func SearchContainerByFilter(filterMap map[string]string) ([]types.Container, er
if err != nil {
return []types.Container{}, err
}
+ defer cli.Close()
+ ctx, cancel := context.WithTimeout(context.Background(), dockerAPIRequestTimeout)
+ defer cancel()
filterArgs := filters.NewArgs()
for key, val := range filterMap {
filterArgs.Add(key, val)
}
- containers, err := cli.ContainerList(context.Background(), types.ContainerListOptions{
+ containers, err := cli.ContainerList(ctx, types.ContainerListOptions{
All: true,
Filters: filterArgs,
})
@@ -113,13 +119,16 @@ func SearchRunningContainerByFilter(filterMap map[string]string) ([]types.Contai
if err != nil {
return []types.Container{}, err
}
+ defer cli.Close()
+ ctx, cancel := context.WithTimeout(context.Background(), dockerAPIRequestTimeout)
+ defer cancel()
filterArgs := filters.NewArgs()
for key, val := range filterMap {
filterArgs.Add(key, val)
}
- containers, err := cli.ContainerList(context.Background(), types.ContainerListOptions{
+ containers, err := cli.ContainerList(ctx, types.ContainerListOptions{
Filters: filterArgs,
})
@@ -131,16 +140,19 @@ func StopAndRemoveContainer(containerId string) error {
if err != nil {
return err
}
+ defer cli.Close()
+ ctx, cancel := context.WithTimeout(context.Background(), dockerAPIRequestTimeout)
+ defer cancel()
// Try to stop using default timeout we are using for beast
- err = cli.ContainerStop(context.Background(), containerId, &defaults.DefaultDockerStopTimeout)
+ err = cli.ContainerStop(ctx, containerId, &defaults.DefaultDockerStopTimeout)
if err != nil {
return err
}
log.Debug("Stopped container with ID ", containerId)
log.Debug("Removing container with ID ", containerId)
- err = cli.ContainerRemove(context.Background(), containerId, types.ContainerRemoveOptions{
+ err = cli.ContainerRemove(ctx, containerId, types.ContainerRemoveOptions{
RemoveVolumes: false,
RemoveLinks: false,
Force: true,
@@ -150,12 +162,23 @@ func StopAndRemoveContainer(containerId string) error {
}
func CreateContainerFromImage(containerConfig *CreateContainerConfig) (string, error) {
+ if containerConfig == nil {
+ return "", errors.New("container configuration is required")
+ }
+ if containerConfig.ImageId == "" {
+ return "", errors.New("container image ID is required")
+ }
+ if err := ValidateResourceLimits(containerConfig.CPUShares, containerConfig.CPUsLimit, containerConfig.Memory, containerConfig.PidsLimit); err != nil {
+ return "", fmt.Errorf("invalid container resource limits: %w", err)
+ }
containerName := containerConfig.ContainerName
- ctx := context.Background()
+ ctx, cancel := context.WithTimeout(context.Background(), dockerAPILongTimeout)
+ defer cancel()
cli, err := newDockerClient()
if err != nil {
return "", err
}
+ defer cli.Close()
portSet := make(nat.PortSet)
portMap := make(nat.PortMap)
@@ -191,16 +214,7 @@ func CreateContainerFromImage(containerConfig *CreateContainerConfig) (string, e
Labels: labels,
}
- var mountBindings []mount.Mount
- for src, dest := range containerConfig.MountsMap {
- mnt := mount.Mount{
- Type: mount.TypeBind,
- Source: src,
- Target: dest,
- }
-
- mountBindings = append(mountBindings, mnt)
- }
+ mountBindings := readOnlyBindMounts(containerConfig.MountsMap)
resources := container.Resources{
NanoCPUs: int64(containerConfig.CPUsLimit * 1e9),
@@ -215,6 +229,7 @@ func CreateContainerFromImage(containerConfig *CreateContainerConfig) (string, e
NetworkMode: container.NetworkMode(containerConfig.ContainerNetwork),
Resources: resources,
}
+ applyDefaultContainerSecurity(hostConfig)
createResp, err := cli.ContainerCreate(ctx, config, hostConfig, nil, nil, containerName)
if err != nil {
@@ -228,6 +243,10 @@ func CreateContainerFromImage(containerConfig *CreateContainerConfig) (string, e
}
if err := cli.ContainerStart(ctx, containerId, types.ContainerStartOptions{}); err != nil {
+ removeErr := cli.ContainerRemove(ctx, containerId, types.ContainerRemoveOptions{Force: true})
+ if removeErr != nil {
+ log.Errorf("Error while removing failed container %s: %s", containerId, removeErr)
+ }
log.Errorf("Error while starting the container : %s", err)
return "", err
}
@@ -235,13 +254,34 @@ func CreateContainerFromImage(containerConfig *CreateContainerConfig) (string, e
return containerId, nil
}
+func readOnlyBindMounts(mounts map[string]string) []mount.Mount {
+ bindings := make([]mount.Mount, 0, len(mounts))
+ for src, dest := range mounts {
+ bindings = append(bindings, mount.Mount{
+ Type: mount.TypeBind,
+ Source: src,
+ Target: dest,
+ ReadOnly: true,
+ })
+ }
+ return bindings
+}
+
+func applyDefaultContainerSecurity(hostConfig *container.HostConfig) {
+ hostConfig.CapDrop = []string{"ALL"}
+ hostConfig.SecurityOpt = []string{"no-new-privileges"}
+}
+
func GetContainerStdLogs(containerID string) (*Log, error) {
cli, err := newDockerClient()
if err != nil {
return nil, err
}
+ defer cli.Close()
+ ctx, cancel := context.WithTimeout(context.Background(), dockerAPIRequestTimeout)
+ defer cancel()
- stdout, err := cli.ContainerLogs(context.Background(), containerID, types.ContainerLogsOptions{
+ stdout, err := cli.ContainerLogs(ctx, containerID, types.ContainerLogsOptions{
ShowStdout: true,
Details: true,
})
@@ -250,9 +290,12 @@ func GetContainerStdLogs(containerID string) (*Log, error) {
}
defer stdout.Close()
- stdoutlogs, _ := ioutil.ReadAll(stdout)
+ stdoutlogs, err := readContainerLogs(stdout, maxContainerLogBytes)
+ if err != nil {
+ return nil, fmt.Errorf("read container stdout: %w", err)
+ }
- stderr, err := cli.ContainerLogs(context.Background(), containerID, types.ContainerLogsOptions{
+ stderr, err := cli.ContainerLogs(ctx, containerID, types.ContainerLogsOptions{
ShowStderr: true,
Details: true,
})
@@ -261,37 +304,60 @@ func GetContainerStdLogs(containerID string) (*Log, error) {
}
defer stderr.Close()
- stderrlogs, _ := ioutil.ReadAll(stderr)
+ stderrlogs, err := readContainerLogs(stderr, maxContainerLogBytes-int64(len(stdoutlogs)))
+ if err != nil {
+ return nil, fmt.Errorf("read container stderr: %w", err)
+ }
return &Log{Stdout: string(stdoutlogs), Stderr: string(stderrlogs)}, nil
}
-func ShowLiveContainerLogs(containerID string) {
+func readContainerLogs(reader io.Reader, limit int64) ([]byte, error) {
+ logs, err := io.ReadAll(io.LimitReader(reader, limit+1))
+ if err != nil {
+ return nil, err
+ }
+ if int64(len(logs)) > limit {
+ return nil, errContainerLogLimit
+ }
+ return logs, nil
+}
+
+func ShowLiveContainerLogs(containerID string) error {
cli, err := newDockerClient()
if err != nil {
- log.Error(err)
+ return err
}
+ defer cli.Close()
+ ctx, cancel := context.WithTimeout(context.Background(), dockerAPIRequestTimeout)
+ defer cancel()
- stream, err := cli.ContainerLogs(context.Background(), containerID, types.ContainerLogsOptions{
+ stream, err := cli.ContainerLogs(ctx, containerID, types.ContainerLogsOptions{
ShowStdout: true,
ShowStderr: true,
Details: true,
})
if err != nil {
- log.Error(err)
+ return err
}
defer stream.Close()
- logs, _ := ioutil.ReadAll(stream)
+ logs, err := readContainerLogs(stream, maxContainerLogBytes)
+ if err != nil {
+ return fmt.Errorf("read container logs: %w", err)
+ }
fmt.Println(string(logs))
+ return nil
}
func CommitContainer(containerId string) (string, error) {
- ctx := context.Background()
+ ctx, cancel := context.WithTimeout(context.Background(), dockerAPILongTimeout)
+ defer cancel()
cli, err := newDockerClient()
if err != nil {
return "", err
}
+ defer cli.Close()
commitResp, err := cli.ContainerCommit(ctx, containerId, types.ContainerCommitOptions{})
if err != nil {
@@ -309,35 +375,29 @@ func DeployContainerFromCompose(challengeName string, projectName string, staged
// Deploy with project name - Docker Compose automatically labels containers with
// com.docker.compose.project=
- upCmd := exec.Command("docker", "compose",
+ arguments := []string{"compose",
"-f", composeFile,
"-p", projectName,
- "up", "-d")
+ "up", "-d"}
environment := os.Environ()
for variable, port := range ports {
environment = append(environment, fmt.Sprintf("%s=%s", variable, strconv.FormatUint(uint64(port), 10)))
}
- upCmd.Env = environment
-
- var upOutput bytes.Buffer
- upCmd.Stdout = &upOutput
- upCmd.Stderr = &upOutput
-
- if err := upCmd.Run(); err != nil {
- log.Errorf("docker compose up failed for challenge %s. Output:\n%s", challengeName, upOutput.String())
- return "", fmt.Errorf("error while running docker compose up: %v", err)
+ upOutput, err := runRuntimeCommand("docker", arguments, runtimeCommandOptions{environment: environment})
+ if err != nil {
+ log.Errorf("docker compose up failed for challenge %s. Output:\n%s", challengeName, upOutput)
+ return "", cleanupFailedComposeDeployment(projectName, fmt.Errorf("run docker compose up: %w", err))
}
if err := validateAllComposeServicesRunning(projectName, challengeName); err != nil {
- return "", err
+ return "", cleanupFailedComposeDeployment(projectName, err)
}
primaryContainerId, err := getPrimaryComposeContainerId(projectName)
if err != nil {
- log.Warnf("Could not get primary container ID for challenge %s: %v", challengeName, err)
- return "", nil // Return empty string but success
+ return "", cleanupFailedComposeDeployment(projectName, fmt.Errorf("get primary container for challenge %s: %w", challengeName, err))
}
log.Debugf("Verified challenge %s services are running. Primary container: %s", challengeName, primaryContainerId)
@@ -345,16 +405,12 @@ func DeployContainerFromCompose(challengeName string, projectName string, staged
}
func validateAllComposeServicesRunning(projectName, challengeName string) error {
- psCmd := exec.Command("docker", "compose", "-p", projectName, "ps", "--format", "json")
- var psOutput bytes.Buffer
- psCmd.Stdout = &psOutput
- psCmd.Stderr = &psOutput
-
- if err := psCmd.Run(); err != nil {
- return fmt.Errorf("error checking container status after compose up for challenge %s. Output:\n%s", challengeName, psOutput.String())
+ psOutput, err := runRuntimeCommand("docker", []string{"compose", "-p", projectName, "ps", "--format", "json"}, runtimeCommandOptions{})
+ if err != nil {
+ return fmt.Errorf("error checking container status after compose up for challenge %s: %w. Output:\n%s", challengeName, err, psOutput)
}
- output := strings.TrimSpace(psOutput.String())
+ output := strings.TrimSpace(psOutput)
if output == "" {
return fmt.Errorf("no services found after compose up for challenge %s", challengeName)
}
@@ -405,15 +461,12 @@ func validateAllComposeServicesRunning(projectName, challengeName string) error
// gets the first container ID from a compose project
func getPrimaryComposeContainerId(projectName string) (string, error) {
- psCmd := exec.Command("docker", "compose", "-p", projectName, "ps", "-q")
- var output bytes.Buffer
- psCmd.Stdout = &output
-
- if err := psCmd.Run(); err != nil {
+ output, err := runRuntimeCommand("docker", []string{"compose", "-p", projectName, "ps", "-q"}, runtimeCommandOptions{})
+ if err != nil {
return "", fmt.Errorf("failed to get container IDs: %v", err)
}
- containerIds := strings.Fields(strings.TrimSpace(output.String()))
+ containerIds := strings.Fields(strings.TrimSpace(output))
if len(containerIds) == 0 {
return "", fmt.Errorf("no containers found for project %s", projectName)
}
@@ -429,13 +482,9 @@ func getPrimaryComposeContainerId(projectName string) (string, error) {
func ComposeDownProject(projectName string) error {
log.Debugf("Stopping docker compose project %s", projectName)
- downCmd := exec.Command("docker", "compose", "-p", projectName, "down")
- var downOutput bytes.Buffer
- downCmd.Stdout = &downOutput
- downCmd.Stderr = &downOutput
-
- if err := downCmd.Run(); err != nil {
- return fmt.Errorf("docker compose down failed for project %s: %v. Output: %s", projectName, err, downOutput.String())
+ downOutput, err := runRuntimeCommand("docker", []string{"compose", "-p", projectName, "down"}, runtimeCommandOptions{})
+ if err != nil {
+ return fmt.Errorf("docker compose down failed for project %s: %v. Output: %s", projectName, err, downOutput)
}
log.Debugf("Successfully stopped compose project %s", projectName)
@@ -446,17 +495,19 @@ func ComposeDownProject(projectName string) error {
func ComposePurgeProject(projectName string) error {
log.Debugf("Purging docker compose project %s", projectName)
- purgeCmd := exec.Command("docker", "compose", "-p", projectName,
- "down", "--remove-orphans", "--volumes", "--rmi", "all")
-
- var purgeOutput bytes.Buffer
- purgeCmd.Stdout = &purgeOutput
- purgeCmd.Stderr = &purgeOutput
-
- if err := purgeCmd.Run(); err != nil {
- return fmt.Errorf("docker compose purge failed for project %s: %v. Output: %s", projectName, err, purgeOutput.String())
+ purgeOutput, err := runRuntimeCommand("docker", []string{"compose", "-p", projectName,
+ "down", "--remove-orphans", "--volumes", "--rmi", "all"}, runtimeCommandOptions{})
+ if err != nil {
+ return fmt.Errorf("docker compose purge failed for project %s: %v. Output: %s", projectName, err, purgeOutput)
}
log.Debugf("Successfully purged compose project %s", projectName)
return nil
}
+
+func cleanupFailedComposeDeployment(projectName string, deploymentErr error) error {
+ if cleanupErr := ComposeDownProject(projectName); cleanupErr != nil {
+ return errors.Join(deploymentErr, fmt.Errorf("clean up failed compose deployment: %w", cleanupErr))
+ }
+ return deploymentErr
+}
diff --git a/pkg/cr/containers_integration_test.go b/pkg/cr/containers_integration_test.go
index f48fa24d..1322211b 100644
--- a/pkg/cr/containers_integration_test.go
+++ b/pkg/cr/containers_integration_test.go
@@ -27,6 +27,10 @@ func TestCreateSearchAndRemoveContainerIntegration(t *testing.T) {
ContainerName: containerName,
ChallengeName: challengeName,
MountsMap: map[string]string{},
+ CPUShares: 512,
+ CPUsLimit: 0.25,
+ Memory: 512 << 20,
+ PidsLimit: 100,
Labels: map[string]string{
"beast.integration_test": "true",
},
diff --git a/pkg/cr/exec.go b/pkg/cr/exec.go
new file mode 100644
index 00000000..b69fc242
--- /dev/null
+++ b/pkg/cr/exec.go
@@ -0,0 +1,116 @@
+package cr
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "sync"
+
+ "github.com/docker/docker/api/types"
+ "github.com/docker/docker/pkg/stdcopy"
+)
+
+const DefaultExecOutputLimit = 1 << 20
+
+type ExecResult struct {
+ Stdout string
+ Stderr string
+ ExitCode int
+ Truncated bool
+}
+
+type cappedBuffer struct {
+ buffer bytes.Buffer
+ remaining int
+ truncated bool
+ mutex sync.Mutex
+}
+
+func (buffer *cappedBuffer) Write(data []byte) (int, error) {
+ buffer.mutex.Lock()
+ defer buffer.mutex.Unlock()
+ written := len(data)
+ if len(data) > buffer.remaining {
+ data = data[:buffer.remaining]
+ buffer.truncated = true
+ }
+ if len(data) > 0 {
+ _, _ = buffer.buffer.Write(data)
+ buffer.remaining -= len(data)
+ }
+ return written, nil
+}
+
+func (buffer *cappedBuffer) String() string {
+ buffer.mutex.Lock()
+ defer buffer.mutex.Unlock()
+ return buffer.buffer.String()
+}
+
+func (buffer *cappedBuffer) Truncated() bool {
+ buffer.mutex.Lock()
+ defer buffer.mutex.Unlock()
+ return buffer.truncated
+}
+
+func ExecContainer(ctx context.Context, containerID string, command []string, outputLimit int) (ExecResult, error) {
+ if containerID == "" {
+ return ExecResult{}, fmt.Errorf("container ID is empty")
+ }
+ if len(command) == 0 {
+ return ExecResult{}, fmt.Errorf("command is empty")
+ }
+ if outputLimit <= 0 {
+ outputLimit = DefaultExecOutputLimit
+ }
+
+ client, err := newDockerClient()
+ if err != nil {
+ return ExecResult{}, fmt.Errorf("create Docker client: %w", err)
+ }
+ defer client.Close()
+ exec, err := client.ContainerExecCreate(ctx, containerID, types.ExecConfig{
+ User: "0",
+ AttachStdout: true,
+ AttachStderr: true,
+ Cmd: command,
+ })
+ if err != nil {
+ return ExecResult{}, fmt.Errorf("create container exec: %w", err)
+ }
+ response, err := client.ContainerExecAttach(ctx, exec.ID, types.ExecStartCheck{})
+ if err != nil {
+ return ExecResult{}, fmt.Errorf("attach container exec: %w", err)
+ }
+ defer response.Close()
+
+ stopCancellation := make(chan struct{})
+ defer close(stopCancellation)
+ go func() {
+ select {
+ case <-ctx.Done():
+ response.Close()
+ case <-stopCancellation:
+ }
+ }()
+
+ stdout := &cappedBuffer{remaining: (outputLimit + 1) / 2}
+ stderr := &cappedBuffer{remaining: outputLimit / 2}
+ if _, err := stdcopy.StdCopy(stdout, stderr, response.Reader); err != nil && ctx.Err() == nil {
+ return ExecResult{}, fmt.Errorf("read container exec output: %w", err)
+ }
+ if err := ctx.Err(); err != nil {
+ return ExecResult{}, err
+ }
+ inspection, err := client.ContainerExecInspect(ctx, exec.ID)
+ if err != nil {
+ return ExecResult{}, fmt.Errorf("inspect container exec: %w", err)
+ }
+
+ return ExecResult{
+ Stdout: stdout.String(),
+ Stderr: stderr.String(),
+ ExitCode: inspection.ExitCode,
+ Truncated: stdout.Truncated() || stderr.Truncated(),
+ }, nil
+}
diff --git a/pkg/cr/exec_test.go b/pkg/cr/exec_test.go
new file mode 100644
index 00000000..fe58f2eb
--- /dev/null
+++ b/pkg/cr/exec_test.go
@@ -0,0 +1,21 @@
+package cr
+
+import "testing"
+
+func TestCappedBufferDiscardsExcessWithoutBlockingWriter(t *testing.T) {
+ buffer := &cappedBuffer{remaining: 4}
+ data := []byte("abcdefgh")
+ written, err := buffer.Write(data)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if written != len(data) {
+ t.Fatalf("reported written bytes = %d, want %d", written, len(data))
+ }
+ if got := buffer.String(); got != "abcd" {
+ t.Fatalf("buffer = %q", got)
+ }
+ if !buffer.Truncated() {
+ t.Fatal("expected truncation marker")
+ }
+}
diff --git a/pkg/cr/health_check.go b/pkg/cr/health_check.go
index 0291e2fb..bb18a5d4 100644
--- a/pkg/cr/health_check.go
+++ b/pkg/cr/health_check.go
@@ -1,15 +1,14 @@
package cr
import (
- "bytes"
"encoding/json"
"fmt"
+ "strings"
+
"github.com/docker/docker/api/types"
"github.com/sdslabs/beastv4/core"
"github.com/sdslabs/beastv4/core/cache"
log "github.com/sirupsen/logrus"
- "os/exec"
- "strings"
)
func CleanupOrphans() {
@@ -92,7 +91,7 @@ func CleanupOrphanedComposeInstances() {
if err != nil {
log.Infof("Removing orphaned compose instance project: %s (instance %s) on %s", projectName, instanceID, core.LOCALHOST)
- err = composeDownProject(projectName)
+ err = composeDownOrphanProject(projectName)
if err != nil {
log.Warnf("Failed to remove orphaned compose project %s: %v", projectName, err)
}
@@ -106,13 +105,9 @@ func CleanupOrphanedComposeInstances() {
// getOrphanedComposeInstanceProjects returns a list of docker compose project names
// that match the instance naming pattern (beast-instance-*)
func getOrphanedComposeInstanceProjects() ([]string, error) {
- cmd := exec.Command("docker", "compose", "ls", "--format", "json")
- var output bytes.Buffer
- cmd.Stdout = &output
- cmd.Stderr = &output
-
- if err := cmd.Run(); err != nil {
- return nil, fmt.Errorf("docker compose ls failed: %v, output: %s", err, output.String())
+ output, err := runRuntimeCommand("docker", []string{"compose", "ls", "--format", "json"}, runtimeCommandOptions{})
+ if err != nil {
+ return nil, fmt.Errorf("docker compose ls failed: %v, output: %s", err, output)
}
type ComposeProject struct {
@@ -121,7 +116,7 @@ func getOrphanedComposeInstanceProjects() ([]string, error) {
}
var projects []ComposeProject
- outputStr := strings.TrimSpace(output.String())
+ outputStr := strings.TrimSpace(output)
if outputStr == "" {
return nil, nil
}
@@ -150,17 +145,11 @@ func getOrphanedComposeInstanceProjects() ([]string, error) {
return instanceProjects, nil
}
-// composeDownProject removes a docker compose project by name
-func composeDownProject(projectName string) error {
- cmd := exec.Command("docker", "compose", "-p", projectName, "down", "--remove-orphans", "-v")
- var output bytes.Buffer
- cmd.Stdout = &output
- cmd.Stderr = &output
-
- if err := cmd.Run(); err != nil {
- return fmt.Errorf("docker compose down failed: %v, output: %s", err, output.String())
+func composeDownOrphanProject(projectName string) error {
+ output, err := runRuntimeCommand("docker", []string{"compose", "-p", projectName, "down", "--remove-orphans", "-v"}, runtimeCommandOptions{})
+ if err != nil {
+ return fmt.Errorf("docker compose down failed: %v, output: %s", err, output)
}
-
log.Debugf("Successfully removed compose project %s", projectName)
return nil
}
diff --git a/pkg/cr/images.go b/pkg/cr/images.go
index fe4f6a39..290f4496 100644
--- a/pkg/cr/images.go
+++ b/pkg/cr/images.go
@@ -2,28 +2,77 @@ package cr
import (
"bytes"
+ "context"
+ "errors"
"fmt"
+ "io"
"os"
"os/exec"
"path/filepath"
- "strings"
+ "sync"
+ "time"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters"
+ "github.com/docker/go-units"
"github.com/sdslabs/beastv4/core"
"github.com/sdslabs/beastv4/utils"
log "github.com/sirupsen/logrus"
- "golang.org/x/net/context"
)
+const (
+ maxBuildOutput = 4 << 20
+ buildTimeout = 30 * time.Minute
+)
+
+var errBuildOutputLimit = errors.New("build output exceeds 4 MiB limit")
+
+type BuildLimits struct {
+ CPUShares int64
+ CPUs float32
+ Memory int64
+ Pids int64
+}
+
+type boundedBuildOutput struct {
+ mu sync.Mutex
+ buffer bytes.Buffer
+ exceeded bool
+}
+
+func (output *boundedBuildOutput) Write(data []byte) (int, error) {
+ output.mu.Lock()
+ defer output.mu.Unlock()
+ remaining := maxBuildOutput - output.buffer.Len()
+ if remaining <= 0 {
+ output.exceeded = true
+ return 0, errBuildOutputLimit
+ }
+ if len(data) > remaining {
+ _, _ = output.buffer.Write(data[:remaining])
+ output.exceeded = true
+ return remaining, errBuildOutputLimit
+ }
+ return output.buffer.Write(data)
+}
+
+func (output *boundedBuildOutput) Buffer() *bytes.Buffer {
+ output.mu.Lock()
+ defer output.mu.Unlock()
+ return bytes.NewBuffer(append([]byte(nil), output.buffer.Bytes()...))
+}
+
func RemoveImage(imageId string) error {
cli, err := newDockerClient()
if err != nil {
return err
}
+ defer cli.Close()
+ ctx, cancel := context.WithTimeout(context.Background(), dockerAPIRequestTimeout)
+ defer cancel()
- _, err = cli.ImageRemove(context.Background(), imageId, types.ImageRemoveOptions{
+ _, err = cli.ImageRemove(ctx, imageId, types.ImageRemoveOptions{
Force: false,
PruneChildren: true,
})
@@ -32,11 +81,13 @@ func RemoveImage(imageId string) error {
}
func CheckIfImageExists(imageId string) (bool, error) {
- ctx := context.Background()
+ ctx, cancel := context.WithTimeout(context.Background(), dockerAPIRequestTimeout)
+ defer cancel()
cli, err := newDockerClient()
if err != nil {
return false, err
}
+ defer cli.Close()
inspectVal, _, err := cli.ImageInspectWithRaw(ctx, imageId)
if err != nil {
@@ -55,13 +106,16 @@ func SearchImageByFilter(filterMap map[string]string) ([]types.ImageSummary, err
if err != nil {
return []types.ImageSummary{}, err
}
+ defer cli.Close()
+ ctx, cancel := context.WithTimeout(context.Background(), dockerAPIRequestTimeout)
+ defer cancel()
filterArgs := filters.NewArgs()
for key, val := range filterMap {
filterArgs.Add(key, val)
}
- images, err := cli.ImageList(context.Background(), types.ImageListOptions{
+ images, err := cli.ImageList(ctx, types.ImageListOptions{
All: false,
Filters: filterArgs,
})
@@ -69,8 +123,12 @@ func SearchImageByFilter(filterMap map[string]string) ([]types.ImageSummary, err
return images, err
}
-func BuildImageFromTarContext(challengeName, challengeTag, tarContextPath, dockerCtxFile string, noCache bool) (*bytes.Buffer, string, error) {
- ctx := context.Background()
+func BuildImageFromTarContext(challengeName, challengeTag, tarContextPath, dockerCtxFile string, noCache bool, limits BuildLimits) (*bytes.Buffer, string, error) {
+ if err := limits.Validate(); err != nil {
+ return nil, "", fmt.Errorf("invalid build resource limits: %w", err)
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), buildTimeout)
+ defer cancel()
builderContext, err := os.Open(tarContextPath)
if err != nil {
return nil, "", fmt.Errorf("error while opening staged file :: %s", tarContextPath)
@@ -82,6 +140,16 @@ func BuildImageFromTarContext(challengeName, challengeTag, tarContextPath, docke
Remove: true,
Dockerfile: dockerCtxFile,
NoCache: noCache,
+ CPUShares: limits.CPUShares,
+ CPUPeriod: 100000,
+ CPUQuota: CPUQuota(limits.CPUs),
+ Memory: limits.Memory,
+ MemorySwap: limits.Memory,
+ Ulimits: []*units.Ulimit{{
+ Name: "nproc",
+ Soft: limits.Pids,
+ Hard: limits.Pids,
+ }},
Labels: map[string]string{
"beast.challenge": challengeName,
"com.sdslabs.beast.project": utils.ProjectNameNotInstanced(challengeName),
@@ -93,6 +161,7 @@ func BuildImageFromTarContext(challengeName, challengeTag, tarContextPath, docke
if err != nil {
return nil, "", fmt.Errorf("error while creating a docker client for beast: %s", err)
}
+ defer dockerClient.Close()
log.Debug("Image build in process")
imageBuildResp, err := dockerClient.ImageBuild(ctx, builderContext, buildOptions)
@@ -102,25 +171,42 @@ func BuildImageFromTarContext(challengeName, challengeTag, tarContextPath, docke
defer imageBuildResp.Body.Close()
buf := new(bytes.Buffer)
- buf.ReadFrom(imageBuildResp.Body)
+ written, err := io.Copy(buf, io.LimitReader(imageBuildResp.Body, maxBuildOutput+1))
+ if err != nil {
+ return buf, "", fmt.Errorf("read image build output: %w", err)
+ }
+ if written > maxBuildOutput {
+ return buf, "", errBuildOutputLimit
+ }
+ if err := ctx.Err(); err != nil {
+ return buf, "", fmt.Errorf("image build deadline: %w", err)
+ }
images, err := SearchImageByFilter(map[string]string{"reference": fmt.Sprintf("%s:latest", challengeTag)})
+ if err != nil {
+ return buf, "", fmt.Errorf("find built image: %w", err)
+ }
if len(images) > 0 {
log.Infof("Image ID for the image built is : %s", images[0].ID[7:])
return buf, images[0].ID[7:], nil
}
- return buf, "", err
+ return buf, "", fmt.Errorf("Docker build completed without producing image %s:latest", challengeTag)
}
// TODO: find a better way to build images from docker-compose instead of cmd running
func BuildImagesFromCompose(challengeName, challengeTag, stagedPath, ComposeFile string, noCache bool) (*bytes.Buffer, error) {
extractPath := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_STAGING_DIR, challengeName, challengeName)
- extractCmd := fmt.Sprintf("mkdir -p %s && tar -xf %s -C %s", extractPath, stagedPath, extractPath)
- err := exec.Command("bash", "-c", extractCmd).Run()
- if err != nil {
- return nil, fmt.Errorf("error while extracting tar file %s to %s: %v", stagedPath, extractPath, err)
+ if _, err := os.Lstat(extractPath); err == nil {
+ if err := os.RemoveAll(extractPath); err != nil {
+ return nil, fmt.Errorf("clear compose extraction directory %s: %w", extractPath, err)
+ }
+ } else if !os.IsNotExist(err) {
+ return nil, fmt.Errorf("inspect compose extraction directory %s: %w", extractPath, err)
+ }
+ if err := utils.ExtractTarGzip(stagedPath, extractPath); err != nil {
+ return nil, fmt.Errorf("extract compose context: %w", err)
}
cmdArgs := []string{"compose", "build"}
@@ -129,18 +215,25 @@ func BuildImagesFromCompose(challengeName, challengeTag, stagedPath, ComposeFile
}
// Note: docker compose build does not support --label flag
// Labels are automatically added to containers during 'docker compose up -p '
- composeCmd := fmt.Sprintf("docker %s", strings.Join(cmdArgs, " "))
log.Debugf("Building image for challenge %s with tag %s", challengeName, challengeTag)
log.Debugf("Running the command: docker %v", cmdArgs)
- cmd := exec.Command("bash", "-c", composeCmd)
+ ctx, cancel := context.WithTimeout(context.Background(), buildTimeout)
+ defer cancel()
+ cmd := exec.CommandContext(ctx, "docker", cmdArgs...)
cmd.Dir = extractPath
- var outBuffer bytes.Buffer
- cmd.Stdout = &outBuffer
- cmd.Stderr = &outBuffer
+ output := &boundedBuildOutput{}
+ cmd.Stdout = output
+ cmd.Stderr = output
if err := cmd.Run(); err != nil {
- return &outBuffer, fmt.Errorf("error while building image for challenge %s with tag %s: %v", challengeName, challengeTag, err)
+ return output.Buffer(), fmt.Errorf("error while building image for challenge %s with tag %s: %v", challengeName, challengeTag, err)
+ }
+ if output.exceeded {
+ return output.Buffer(), errBuildOutputLimit
+ }
+ if err := ctx.Err(); err != nil {
+ return output.Buffer(), fmt.Errorf("Compose build deadline: %w", err)
}
- return &outBuffer, nil
+ return output.Buffer(), nil
}
diff --git a/pkg/cr/resources.go b/pkg/cr/resources.go
new file mode 100644
index 00000000..d83798d9
--- /dev/null
+++ b/pkg/cr/resources.go
@@ -0,0 +1,41 @@
+package cr
+
+import (
+ "fmt"
+ "math"
+)
+
+const (
+ minimumCPULimit float32 = 0.01
+ minimumCPUShares int64 = 2
+ minimumContainerBytes int64 = 6 << 20
+ minimumPidsLimit int64 = 1
+)
+
+func ValidateResourceLimits(cpuShares int64, cpus float32, memory, pids int64) error {
+ if cpuShares < minimumCPUShares {
+ return fmt.Errorf("cpu shares must be at least %d", minimumCPUShares)
+ }
+ if math.IsNaN(float64(cpus)) || math.IsInf(float64(cpus), 0) || cpus < minimumCPULimit {
+ return fmt.Errorf("CPU limit must be finite and at least %.2f", minimumCPULimit)
+ }
+ if memory < minimumContainerBytes {
+ return fmt.Errorf("memory limit must be at least %d bytes", minimumContainerBytes)
+ }
+ if pids < minimumPidsLimit {
+ return fmt.Errorf("PID limit must be at least %d", minimumPidsLimit)
+ }
+ return nil
+}
+
+func CPUQuota(cpus float32) int64 {
+ quota := int64(math.Ceil(float64(cpus) * 100000))
+ if quota < 1000 {
+ return 1000
+ }
+ return quota
+}
+
+func (limits BuildLimits) Validate() error {
+ return ValidateResourceLimits(limits.CPUShares, limits.CPUs, limits.Memory, limits.Pids)
+}
diff --git a/pkg/cr/resources_test.go b/pkg/cr/resources_test.go
new file mode 100644
index 00000000..4451bbd9
--- /dev/null
+++ b/pkg/cr/resources_test.go
@@ -0,0 +1,38 @@
+package cr
+
+import (
+ "math"
+ "testing"
+)
+
+func TestValidateResourceLimitsRejectsUnsafeValues(t *testing.T) {
+ tests := []struct {
+ name string
+ shares int64
+ cpus float32
+ memory int64
+ pids int64
+ }{
+ {name: "CPU shares", shares: 1, cpus: 1, memory: minimumContainerBytes, pids: 1},
+ {name: "CPU quota", shares: 2, cpus: 0.001, memory: minimumContainerBytes, pids: 1},
+ {name: "CPU NaN", shares: 2, cpus: float32(math.NaN()), memory: minimumContainerBytes, pids: 1},
+ {name: "memory", shares: 2, cpus: 1, memory: minimumContainerBytes - 1, pids: 1},
+ {name: "PIDs", shares: 2, cpus: 1, memory: minimumContainerBytes, pids: 0},
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ if err := ValidateResourceLimits(test.shares, test.cpus, test.memory, test.pids); err == nil {
+ t.Fatal("expected resource validation error")
+ }
+ })
+ }
+}
+
+func TestCPUQuotaNeverDisablesLimit(t *testing.T) {
+ if quota := CPUQuota(0.000001); quota != 1000 {
+ t.Fatalf("expected minimum quota, got %d", quota)
+ }
+ if quota := CPUQuota(0.25); quota != 25000 {
+ t.Fatalf("expected quarter CPU quota, got %d", quota)
+ }
+}
diff --git a/pkg/cr/runtime_command.go b/pkg/cr/runtime_command.go
new file mode 100644
index 00000000..7986dfbe
--- /dev/null
+++ b/pkg/cr/runtime_command.go
@@ -0,0 +1,80 @@
+package cr
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "os/exec"
+ "sync"
+ "time"
+)
+
+const (
+ runtimeCommandTimeout = 2 * time.Minute
+ maxRuntimeCommandOutput = 1 << 20
+)
+
+var errRuntimeCommandOutputLimit = errors.New("runtime command output exceeds 1 MiB limit")
+
+type runtimeCommandOptions struct {
+ directory string
+ environment []string
+ timeout time.Duration
+}
+
+type boundedRuntimeOutput struct {
+ mutex sync.Mutex
+ buffer bytes.Buffer
+ remaining int
+ truncated bool
+}
+
+func (output *boundedRuntimeOutput) Write(data []byte) (int, error) {
+ output.mutex.Lock()
+ defer output.mutex.Unlock()
+ written := len(data)
+ if len(data) > output.remaining {
+ data = data[:output.remaining]
+ output.truncated = true
+ }
+ if len(data) > 0 {
+ _, _ = output.buffer.Write(data)
+ output.remaining -= len(data)
+ }
+ return written, nil
+}
+
+func (output *boundedRuntimeOutput) String() string {
+ output.mutex.Lock()
+ defer output.mutex.Unlock()
+ return output.buffer.String()
+}
+
+func runRuntimeCommand(name string, arguments []string, options runtimeCommandOptions) (string, error) {
+ timeout := options.timeout
+ if timeout <= 0 {
+ timeout = runtimeCommandTimeout
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), timeout)
+ defer cancel()
+
+ command := exec.CommandContext(ctx, name, arguments...)
+ command.Dir = options.directory
+ if options.environment != nil {
+ command.Env = options.environment
+ }
+ output := &boundedRuntimeOutput{remaining: maxRuntimeCommandOutput}
+ command.Stdout = output
+ command.Stderr = output
+
+ err := command.Run()
+ outputText := output.String()
+ if ctx.Err() != nil {
+ return outputText, fmt.Errorf("%s timed out after %s: %w", name, timeout, ctx.Err())
+ }
+ if output.truncated {
+ return outputText, errRuntimeCommandOutputLimit
+ }
+ return outputText, err
+}
diff --git a/pkg/cr/runtime_command_test.go b/pkg/cr/runtime_command_test.go
new file mode 100644
index 00000000..cee83749
--- /dev/null
+++ b/pkg/cr/runtime_command_test.go
@@ -0,0 +1,14 @@
+package cr
+
+import "testing"
+
+func TestBoundedRuntimeOutputEnforcesLimit(t *testing.T) {
+ output := &boundedRuntimeOutput{remaining: 4}
+ written, err := output.Write([]byte("tests"))
+ if err != nil {
+ t.Fatalf("write bounded output: %v", err)
+ }
+ if written != 5 || output.String() != "test" || !output.truncated {
+ t.Fatalf("unexpected bounded output: written=%d output=%q truncated=%t", written, output.String(), output.truncated)
+ }
+}
diff --git a/pkg/notify/discord.go b/pkg/notify/discord.go
index 1f8d24f3..4d7ada5e 100644
--- a/pkg/notify/discord.go
+++ b/pkg/notify/discord.go
@@ -1,10 +1,8 @@
package notify
import (
- "bytes"
"encoding/json"
"fmt"
- "net/http"
"time"
)
@@ -50,19 +48,5 @@ func (d *DiscordNotificationProvider) SendNotification(nType NotificationType, m
return fmt.Errorf("Error while converting payload to JSON : %s", err)
}
- payloadReader := bytes.NewReader(payload)
- req, err := http.NewRequest("POST", d.Request.WebHookURL, payloadReader)
- if err != nil {
- return fmt.Errorf("Error while connecting to webhook url host : %s", err)
- }
-
- req.Header.Set("Content-Type", "application/json")
- client := http.Client{}
- _, err = client.Do(req)
-
- if err != nil {
- return fmt.Errorf("Error while posting payload for notification : %s", err)
- }
-
- return nil
+ return postWebhookJSON(d.Request.WebHookURL, payload)
}
diff --git a/pkg/notify/http.go b/pkg/notify/http.go
new file mode 100644
index 00000000..66d6f618
--- /dev/null
+++ b/pkg/notify/http.go
@@ -0,0 +1,63 @@
+package notify
+
+import (
+ "bytes"
+ "context"
+ "crypto/tls"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "time"
+)
+
+var webhookHTTPClient = &http.Client{
+ Timeout: 10 * time.Second,
+ Transport: &http.Transport{
+ TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
+ DialContext: dialPublicAddress,
+ },
+ CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
+ return http.ErrUseLastResponse
+ },
+}
+
+func dialPublicAddress(ctx context.Context, network, address string) (net.Conn, error) {
+ host, port, err := net.SplitHostPort(address)
+ if err != nil {
+ return nil, fmt.Errorf("split webhook address: %w", err)
+ }
+ addresses, err := net.DefaultResolver.LookupIPAddr(ctx, host)
+ if err != nil {
+ return nil, fmt.Errorf("resolve webhook host: %w", err)
+ }
+ for _, address := range addresses {
+ if !isPublicIP(address.IP) {
+ continue
+ }
+ return (&net.Dialer{Timeout: 5 * time.Second}).DialContext(ctx, network, net.JoinHostPort(address.IP.String(), port))
+ }
+ return nil, fmt.Errorf("webhook host has no public IP addresses")
+}
+
+func isPublicIP(ip net.IP) bool {
+ return ip != nil && !ip.IsPrivate() && !ip.IsLoopback() && !ip.IsLinkLocalUnicast() && !ip.IsLinkLocalMulticast() && !ip.IsUnspecified() && !ip.IsMulticast()
+}
+
+func postWebhookJSON(webhookURL string, payload []byte) error {
+ req, err := http.NewRequest(http.MethodPost, webhookURL, bytes.NewReader(payload))
+ if err != nil {
+ return fmt.Errorf("create webhook request: %w", err)
+ }
+ req.Header.Set("Content-Type", "application/json")
+ response, err := webhookHTTPClient.Do(req)
+ if err != nil {
+ return fmt.Errorf("post webhook payload: %w", err)
+ }
+ defer response.Body.Close()
+ _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4096))
+ if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
+ return fmt.Errorf("webhook returned HTTP status %d", response.StatusCode)
+ }
+ return nil
+}
diff --git a/pkg/notify/http_test.go b/pkg/notify/http_test.go
new file mode 100644
index 00000000..ccc05387
--- /dev/null
+++ b/pkg/notify/http_test.go
@@ -0,0 +1,17 @@
+package notify
+
+import (
+ "net"
+ "testing"
+)
+
+func TestIsPublicIPRejectsInternalDestinations(t *testing.T) {
+ for _, address := range []string{"127.0.0.1", "10.0.0.1", "169.254.169.254", "::1", "fe80::1"} {
+ if isPublicIP(net.ParseIP(address)) {
+ t.Fatalf("expected %s to be rejected", address)
+ }
+ }
+ if !isPublicIP(net.ParseIP("8.8.8.8")) {
+ t.Fatal("expected public address to be accepted")
+ }
+}
diff --git a/pkg/notify/main.go b/pkg/notify/main.go
index d8959b0d..cbed9c4f 100644
--- a/pkg/notify/main.go
+++ b/pkg/notify/main.go
@@ -1,6 +1,7 @@
package notify
import (
+ "errors"
"fmt"
"net/url"
"strings"
@@ -58,8 +59,6 @@ const (
// In the Discord notification provider it was using the same payload which was used for slack.
// By writing "/slack" in the discord WebHookURL, it execute Slack-Compatible Webhook
func NewNotifier(url *url.URL, ProviderType ProviderTypeEnum) Notifier {
- log.Debug("Inside notifier: Webhook URL: " + url.String())
-
switch ProviderType {
case SlackProvider:
return &SlackNotificationProvider{
@@ -68,9 +67,11 @@ func NewNotifier(url *url.URL, ProviderType ProviderTypeEnum) Notifier {
},
}
case DiscordProvider:
+ discordURL := *url
+ discordURL.Path = strings.TrimSuffix(discordURL.Path, "/") + "/slack"
return &DiscordNotificationProvider{
Request{
- WebHookURL: url.String() + "/slack",
+ WebHookURL: discordURL.String(),
},
}
}
@@ -88,9 +89,16 @@ func (req *Request) FillReqParams() error {
func SendNotification(nType NotificationType, message string) error {
var errs []string
+ if len(message) > 16<<10 {
+ message = message[:16<<10]
+ }
for _, webhook := range config.Cfg.NotificationWebhooks {
if webhook.ServiceName != "" && webhook.URL != "" && webhook.Active {
+ if err := webhook.Validate(); err != nil {
+ errs = append(errs, fmt.Sprintf("invalid %s webhook configuration: %s", webhook.ServiceName, err))
+ continue
+ }
var provider ProviderTypeEnum
url, err := url.ParseRequestURI(webhook.URL)
@@ -132,5 +140,5 @@ func SendNotification(nType NotificationType, message string) error {
return nil
}
- return fmt.Errorf(strings.Join(errs, "\n"))
+ return errors.New(strings.Join(errs, "\n"))
}
diff --git a/pkg/notify/slack.go b/pkg/notify/slack.go
index 9151cb56..318b052f 100644
--- a/pkg/notify/slack.go
+++ b/pkg/notify/slack.go
@@ -1,10 +1,8 @@
package notify
import (
- "bytes"
"encoding/json"
"fmt"
- "net/http"
"time"
)
@@ -49,19 +47,5 @@ func (s *SlackNotificationProvider) SendNotification(nType NotificationType, msg
return fmt.Errorf("Error while converting payload to JSON : %s", err)
}
- payloadReader := bytes.NewReader(payload)
- req, err := http.NewRequest("POST", s.Request.WebHookURL, payloadReader)
- if err != nil {
- return fmt.Errorf("Error while connecting to webhook url host : %s", err)
- }
-
- req.Header.Set("Content-Type", "application/json")
- client := http.Client{}
- _, err = client.Do(req)
-
- if err != nil {
- return fmt.Errorf("Error while posting payload for notification : %s", err)
- }
-
- return nil
+ return postWebhookJSON(s.Request.WebHookURL, payload)
}
diff --git a/pkg/probes/http.go b/pkg/probes/http.go
index a35e08cf..3e37caef 100644
--- a/pkg/probes/http.go
+++ b/pkg/probes/http.go
@@ -3,7 +3,7 @@ package probes
import (
"crypto/tls"
"fmt"
- "io/ioutil"
+ "io"
"net/http"
"net/url"
"time"
@@ -11,9 +11,11 @@ import (
var defaultTransport = http.DefaultTransport.(*http.Transport)
-// New creates Prober that will skip TLS verification while probing.
+const maxProbeBody = 1 << 20
+
+// NewHTTPProber creates a prober that verifies HTTPS endpoints using system roots.
func NewHTTPProber() HttpProber {
- tlsConfig := &tls.Config{InsecureSkipVerify: true}
+ tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12}
return NewWithTLSConfig(tlsConfig)
}
@@ -29,6 +31,14 @@ func setOldTransportDefaults(t *http.Transport) *http.Transport {
}
func NewWithTLSConfig(config *tls.Config) HttpProber {
+ if config == nil {
+ config = &tls.Config{MinVersion: tls.VersionTLS12}
+ } else {
+ config = config.Clone()
+ if config.MinVersion < tls.VersionTLS12 {
+ config.MinVersion = tls.VersionTLS12
+ }
+ }
transport := setOldTransportDefaults(
&http.Transport{
TLSClientConfig: config,
@@ -46,8 +56,9 @@ type HttpProber struct {
// If the HTTP response code is unsuccessful or HTTP communication fails, it returns Failure.
func (pr HttpProber) Probe(url *url.URL, headers http.Header, timeout time.Duration) (ProbeResult, string, error) {
client := &http.Client{
- Timeout: timeout,
- Transport: pr.transport,
+ Timeout: timeout,
+ Transport: pr.transport,
+ CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse },
}
req, err := http.NewRequest("GET", url.String(), nil)
@@ -65,10 +76,13 @@ func (pr HttpProber) Probe(url *url.URL, headers http.Header, timeout time.Durat
}
defer res.Body.Close()
- b, err := ioutil.ReadAll(res.Body)
+ b, err := io.ReadAll(io.LimitReader(res.Body, maxProbeBody+1))
if err != nil {
return Failure, "", err
}
+ if len(b) > maxProbeBody {
+ return Failure, "", fmt.Errorf("HTTP probe response exceeds %d bytes", maxProbeBody)
+ }
body := string(b)
if res.StatusCode >= http.StatusOK && res.StatusCode < http.StatusBadRequest {
diff --git a/pkg/probes/http_test.go b/pkg/probes/http_test.go
new file mode 100644
index 00000000..e4e17c99
--- /dev/null
+++ b/pkg/probes/http_test.go
@@ -0,0 +1,44 @@
+package probes
+
+import (
+ "crypto/tls"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestHTTPProberVerifiesTLSByDefault(t *testing.T) {
+ server := httptest.NewTLSServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
+ response.WriteHeader(http.StatusOK)
+ }))
+ defer server.Close()
+ endpoint, err := url.Parse(server.URL)
+ if err != nil {
+ t.Fatal(err)
+ }
+ result, _, err := NewHTTPProber().Probe(endpoint, nil, time.Second)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if result != Failure {
+ t.Fatalf("untrusted TLS endpoint result = %s, want failure", result)
+ }
+}
+
+func TestHTTPProberBoundsResponseBody(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
+ _, _ = response.Write([]byte(strings.Repeat("x", maxProbeBody+1)))
+ }))
+ defer server.Close()
+ endpoint, err := url.Parse(server.URL)
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, _, err = NewWithTLSConfig(&tls.Config{}).Probe(endpoint, nil, time.Second)
+ if err == nil {
+ t.Fatal("expected oversized response error")
+ }
+}
diff --git a/pkg/remoteManager/container.go b/pkg/remoteManager/container.go
index 25ec6360..46d4a080 100644
--- a/pkg/remoteManager/container.go
+++ b/pkg/remoteManager/container.go
@@ -3,8 +3,11 @@ package remoteManager
import (
"bytes"
"encoding/json"
+ "errors"
"fmt"
"path/filepath"
+ "sort"
+ "strconv"
"strings"
"github.com/docker/docker/api/types"
@@ -12,60 +15,71 @@ import (
"github.com/sdslabs/beastv4/core/config"
"github.com/sdslabs/beastv4/core/database"
"github.com/sdslabs/beastv4/pkg/cr"
- "github.com/sdslabs/beastv4/utils"
log "github.com/sirupsen/logrus"
"gorm.io/gorm"
)
func CreateContainerFromImageRemote(containerConfig cr.CreateContainerConfig, server config.AvailableServer) (string, error) {
- var containerName, containerEnv, exposedPorts, portMap, cpuShareLimit, cpuLimit, memoryLimit, pidLimit, imageID, mountBindings string
+ if err := cr.ValidateResourceLimits(containerConfig.CPUShares, containerConfig.CPUsLimit, containerConfig.Memory, containerConfig.PidsLimit); err != nil {
+ return "", fmt.Errorf("invalid container resource limits: %w", err)
+ }
+ arguments := []string{"docker", "run", "-d", "--cap-drop", "ALL", "--security-opt", "no-new-privileges=true"}
if containerConfig.ContainerName != "" {
- containerName = fmt.Sprintf("--name %s ", containerConfig.ContainerName)
+ arguments = append(arguments, "--name", containerConfig.ContainerName)
}
for _, envVar := range containerConfig.ContainerEnv {
- containerEnv += fmt.Sprintf("--env %s ", envVar)
+ arguments = append(arguments, "--env", envVar)
}
for _, portMapping := range containerConfig.PortMapping {
- portMap += fmt.Sprintf("-p 0.0.0.0:%d:%d/%s ", portMapping.HostPort, portMapping.ContainerPort, containerConfig.TrafficType())
- exposedPorts += fmt.Sprintf("--expose %d ", portMapping.ContainerPort)
+ arguments = append(arguments,
+ "--publish", fmt.Sprintf("0.0.0.0:%d:%d/%s", portMapping.HostPort, portMapping.ContainerPort, containerConfig.TrafficType()),
+ "--expose", strconv.FormatUint(uint64(portMapping.ContainerPort), 10))
}
if containerConfig.CPUShares != 0 {
- cpuShareLimit = fmt.Sprintf("--cpu-shares %d ", containerConfig.CPUShares)
+ arguments = append(arguments, "--cpu-shares", strconv.FormatInt(containerConfig.CPUShares, 10))
}
if containerConfig.CPUsLimit != 0 {
- cpuLimit = fmt.Sprintf("--cpus %f ", containerConfig.CPUsLimit)
+ arguments = append(arguments, "--cpus", strconv.FormatFloat(float64(containerConfig.CPUsLimit), 'f', -1, 32))
}
if containerConfig.Memory != 0 {
- memoryLimit = fmt.Sprintf("--memory %d ", containerConfig.Memory)
+ arguments = append(arguments, "--memory", strconv.FormatInt(containerConfig.Memory, 10))
}
if containerConfig.PidsLimit != 0 {
- pidLimit = fmt.Sprintf("--pids-limit %d ", containerConfig.PidsLimit)
- }
- if containerConfig.ImageId != "" {
- imageID = containerConfig.ImageId
- }
- for src, dest := range containerConfig.MountsMap {
- mountBindings += fmt.Sprintf("--mount type=bind,source=%s,target=%s ", src, dest)
- }
- dockerCommand := fmt.Sprintf("docker run -d %s %s %s %s %s %s %s %s %s %s", containerName, containerEnv, exposedPorts, mountBindings, cpuShareLimit, cpuLimit, memoryLimit, pidLimit, portMap, imageID)
- // fmt.Printf("%s, %s, %s, %s\n", containerName, containerEnv, exposedPorts, portMap)
- // dockerCommand := fmt.Sprintf("docker run \\
- // --name \\
- // --env KEY1=value1 --env KEY2=value2 \\
- // --expose \\
- // --mount type=bind,source=,target= \\
- // --cpus= \\
- // --memory= \\
- // --pids-limit \\
- // -p 0.0.0.0:: \\
- // "
- // );
- output, err := RunCommandOnServer(server, dockerCommand)
+ arguments = append(arguments, "--pids-limit", strconv.FormatInt(containerConfig.PidsLimit, 10))
+ }
+ if containerConfig.ImageId == "" {
+ return "", fmt.Errorf("image ID is required")
+ }
+ mountSources := make([]string, 0, len(containerConfig.MountsMap))
+ for source := range containerConfig.MountsMap {
+ mountSources = append(mountSources, source)
+ }
+ sort.Strings(mountSources)
+ for _, source := range mountSources {
+ resolvedSource := source
+ if !filepath.IsAbs(source) {
+ output, err := RunArgsOnServer(server, "realpath", source)
+ if err != nil {
+ return "", fmt.Errorf("resolve remote mount source %q: %w", source, err)
+ }
+ resolvedSource = strings.TrimSpace(output)
+ if !filepath.IsAbs(resolvedSource) {
+ return "", fmt.Errorf("remote mount source did not resolve to an absolute path: %q", source)
+ }
+ }
+ arguments = append(arguments, "--mount", fmt.Sprintf("type=bind,source=%s,target=%s,readonly", resolvedSource, containerConfig.MountsMap[source]))
+ }
+ arguments = append(arguments, containerConfig.ImageId)
+ output, err := RunArgsOnServer(server, arguments...)
if err != nil {
return "", fmt.Errorf("failed to create container: %s\nOutput: %s", err, output)
}
- log.Println(output[:12])
- return strings.TrimSpace(output[:12]), nil
+ containerID := strings.TrimSpace(output)
+ if len(containerID) < 12 {
+ return "", fmt.Errorf("docker returned invalid container ID %q", containerID)
+ }
+ log.Println(containerID[:12])
+ return containerID[:12], nil
}
// Stops and remove cremote container.
@@ -73,7 +87,7 @@ func CreateContainerFromImageRemote(containerConfig cr.CreateContainerConfig, se
// else just take containerID and find the server config from db
func StopAndRemoveContainerRemote(containerId string, server config.AvailableServer) error {
if server == (config.AvailableServer{}) {
- chall, err := database.QueryChallengeEntries("id", containerId)
+ chall, err := database.QueryChallengeEntries("container_id", containerId)
if err != nil {
if err == (gorm.ErrRecordNotFound) {
log.Debugf("no container with container id %s present", containerId)
@@ -87,14 +101,12 @@ func StopAndRemoveContainerRemote(containerId string, server config.AvailableSer
return fmt.Errorf("no container with container id %s found", containerId)
}
}
- stopCommand := fmt.Sprintf("docker stop %s", containerId)
- if _, err := RunCommandOnServer(server, stopCommand); err != nil {
+ if _, err := RunArgsOnServer(server, "docker", "stop", containerId); err != nil {
return fmt.Errorf("failed to stop container on server %s : %w", server.Host, err)
}
log.Debugf("Stopped container with ID %s on %s", containerId, server.Host)
- removeCommand := fmt.Sprintf("docker rm --force %s", containerId)
- if _, err := RunCommandOnServer(server, removeCommand); err != nil {
+ if _, err := RunArgsOnServer(server, "docker", "rm", "--force", containerId); err != nil {
return fmt.Errorf("failed to remove container on server %s : %w", server.Host, err)
}
log.Printf("Removed container with ID %s on %s", containerId, server.Host)
@@ -104,83 +116,71 @@ func StopAndRemoveContainerRemote(containerId string, server config.AvailableSer
// Function searches containers based on the filter map on all remote servers
func SearchContainerByFilterRemote(filterMap map[string]string, server config.AvailableServer) ([]types.Container, error) {
- filterArgs := ""
- containers := []types.Container{}
- var output string
- var err error
- for key, val := range filterMap {
- filterArgs += fmt.Sprintf("--filter='%s=%s' ", key, val)
- }
- for serverDeployed, server := range config.Cfg.AvailableServers {
- if server.Active {
- if !config.Cfg.UseLocalDockerDaemon(serverDeployed) {
- output, err = RunCommandOnServer(server, fmt.Sprintf("docker ps -a %s --format '{{.ID}}'", filterArgs))
- if err != nil {
- return []types.Container{}, err
- }
- for _, line := range bytes.Split([]byte(output), []byte("\n")) {
- if len(line) > 0 {
- containers = append(containers, types.Container{ID: string(line)})
- }
- }
- }
- }
- }
-
- return containers, nil
+ return searchContainersRemote(filterMap, server, true)
}
// Function searches for running containers based on the filter map on all remote server
func SearchRunningContainerByFilterRemote(filterMap map[string]string, server config.AvailableServer) ([]types.Container, error) {
- filterArgs := ""
- containers := []types.Container{}
- var output string
- var err error
- for key, val := range filterMap {
- filterArgs += fmt.Sprintf("--filter='%s=%s' ", key, val)
- }
- for serverDeployed, server := range config.Cfg.AvailableServers {
- if server.Active {
- if !config.Cfg.UseLocalDockerDaemon(serverDeployed) {
- output, err = RunCommandOnServer(server, fmt.Sprintf("docker ps %s --format '{{.ID}}'", filterArgs))
- if err != nil {
- return []types.Container{}, err
- }
- for _, line := range bytes.Split([]byte(output), []byte("\n")) {
- if len(line) > 0 {
- containers = append(containers, types.Container{ID: string(line)})
- }
- }
+ return searchContainersRemote(filterMap, server, false)
+}
+
+func searchContainersRemote(filterMap map[string]string, server config.AvailableServer, all bool) ([]types.Container, error) {
+ arguments := []string{"docker", "ps"}
+ if all {
+ arguments = append(arguments, "--all")
+ }
+ keys := make([]string, 0, len(filterMap))
+ for key := range filterMap {
+ keys = append(keys, key)
+ }
+ sort.Strings(keys)
+ for _, key := range keys {
+ arguments = append(arguments, "--filter", key+"="+filterMap[key])
+ }
+ arguments = append(arguments, "--format", "{{json .}}")
+ output, err := RunArgsOnServer(server, arguments...)
+ if err != nil {
+ return nil, err
+ }
+ type containerRow struct {
+ ID string
+ Names string
+ Labels string
+ }
+ containers := make([]types.Container, 0)
+ for _, line := range bytes.Split([]byte(output), []byte("\n")) {
+ if len(bytes.TrimSpace(line)) == 0 {
+ continue
+ }
+ var row containerRow
+ if err := json.Unmarshal(line, &row); err != nil {
+ return nil, fmt.Errorf("parse remote container list: %w", err)
+ }
+ labels := make(map[string]string)
+ for _, label := range strings.Split(row.Labels, ",") {
+ key, value, found := strings.Cut(label, "=")
+ if found {
+ labels[key] = value
}
}
+ containers = append(containers, types.Container{ID: row.ID, Names: []string{row.Names}, Labels: labels})
}
-
return containers, nil
}
// Get Containers stdout, stderr logs
func GetContainerStdLogsRemote(containerID string, server config.AvailableServer) (*cr.Log, error) {
- stdoutCmd := fmt.Sprintf("docker logs --details --stdout %s", containerID)
- stderrCmd := fmt.Sprintf("docker logs --details --stderr %s", containerID)
-
- stdout, err := RunCommandOnServer(server, stdoutCmd)
+ stdout, err := RunArgsOnServer(server, "docker", "logs", "--details", containerID)
if err != nil {
return nil, fmt.Errorf("error fetching stdout logs: %w", err)
}
- stderr, err := RunCommandOnServer(server, stderrCmd)
- if err != nil {
- return nil, fmt.Errorf("error fetching stderr logs: %w", err)
- }
-
- return &cr.Log{Stdout: stdout, Stderr: stderr}, nil
+ return &cr.Log{Stdout: stdout}, nil
}
// Get live logs of container
func ShowLiveContainerLogsRemote(containerID string, server config.AvailableServer) error {
- command := fmt.Sprintf("docker logs --details --follow %s", containerID)
-
- output, err := RunCommandOnServer(server, command)
+ output, err := RunArgsOnServer(server, "docker", "logs", "--details", containerID)
if err != nil {
return fmt.Errorf("error streaming live logs: %w", err)
}
@@ -191,9 +191,7 @@ func ShowLiveContainerLogsRemote(containerID string, server config.AvailableServ
// Commit container on remote server
func CommitContainerRemote(containerID string, server config.AvailableServer) (string, error) {
- command := fmt.Sprintf("docker commit %s", containerID)
-
- output, err := RunCommandOnServer(server, command)
+ output, err := RunArgsOnServer(server, "docker", "commit", containerID)
if err != nil {
return "", fmt.Errorf("error committing container: %w", err)
}
@@ -205,22 +203,24 @@ func DeployContainerFromComposeRemote(challengeName string, projectName string,
extractDir := filepath.Join(stagedDir, challengeName)
composeFile := filepath.Join(extractDir, composeFileName)
- upCommand := fmt.Sprintf("%s docker compose -f %s -p %s up -d", utils.PortMappingToEnvironmentVariable(ports), composeFile, projectName)
+ environment := make(map[string]string, len(ports))
+ for variable, port := range ports {
+ environment[variable] = strconv.FormatUint(uint64(port), 10)
+ }
log.Debugf("Deploying challenge %s using docker compose remotely with project %s and file %s", challengeName, projectName, composeFileName)
- upOutput, err := RunCommandOnServer(server, upCommand)
+ upOutput, err := RunArgsWithEnvOnServer(server, environment, "docker", "compose", "-f", composeFile, "-p", projectName, "up", "-d")
if err != nil {
log.Errorf("docker compose up failed for challenge %s. Output:\n%s", challengeName, upOutput)
- return "", fmt.Errorf("error while running docker compose up on remote: %v", err)
+ return "", cleanupFailedComposeDeploymentRemote(projectName, server, fmt.Errorf("run docker compose up on remote: %w", err))
}
if err := validateAllComposeServicesRunningRemote(projectName, challengeName, server); err != nil {
- return "", err
+ return "", cleanupFailedComposeDeploymentRemote(projectName, server, err)
}
primaryContainerId, err := getPrimaryComposeContainerIdRemote(projectName, server)
if err != nil {
- log.Warnf("Could not get primary container ID for challenge %s on remote: %v", challengeName, err)
- return "", nil // Return empty string but success
+ return "", cleanupFailedComposeDeploymentRemote(projectName, server, fmt.Errorf("get primary container for challenge %s on remote: %w", challengeName, err))
}
log.Debugf("Verified challenge %s services are running on remote. Primary container: %s", challengeName, primaryContainerId)
@@ -228,9 +228,8 @@ func DeployContainerFromComposeRemote(challengeName string, projectName string,
}
func validateAllComposeServicesRunningRemote(projectName, challengeName string, server config.AvailableServer) error {
- psCommand := fmt.Sprintf("docker compose -p %s ps --format json", projectName)
- log.Debugf("Verifying docker compose services for challenge %s: %s", challengeName, psCommand)
- psOutput, err := RunCommandOnServer(server, psCommand)
+ log.Debugf("Verifying docker compose services for challenge %s", challengeName)
+ psOutput, err := RunArgsOnServer(server, "docker", "compose", "-p", projectName, "ps", "--format", "json")
if err != nil {
log.Errorf("docker compose ps failed for challenge %s. Output:\n%s", challengeName, psOutput)
return fmt.Errorf("error while verifying docker compose services on remote: %v", err)
@@ -287,16 +286,16 @@ func validateAllComposeServicesRunningRemote(projectName, challengeName string,
// gets the first container ID from a compose project on remote
func getPrimaryComposeContainerIdRemote(projectName string, server config.AvailableServer) (string, error) {
- psCommand := fmt.Sprintf("docker compose -p %s ps -q | head -1", projectName)
- output, err := RunCommandOnServer(server, psCommand)
+ output, err := RunArgsOnServer(server, "docker", "compose", "-p", projectName, "ps", "-q")
if err != nil {
return "", fmt.Errorf("failed to get container IDs on remote: %v", err)
}
- containerId := strings.TrimSpace(output)
- if containerId == "" {
+ containerIDs := strings.Fields(output)
+ if len(containerIDs) == 0 {
return "", fmt.Errorf("no containers found for project %s on remote", projectName)
}
+ containerId := containerIDs[0]
// Return first 12 characters
if len(containerId) >= 12 {
@@ -308,8 +307,7 @@ func getPrimaryComposeContainerIdRemote(projectName string, server config.Availa
// ComposeDownProjectRemote runs docker compose down for an explicit -p project name.
func ComposeDownProjectRemote(projectName string, server config.AvailableServer) error {
log.Debugf("Stopping docker compose project %s on remote", projectName)
- downCommand := fmt.Sprintf("docker compose -p %s down", projectName)
- downOutput, err := RunCommandOnServer(server, downCommand)
+ downOutput, err := RunArgsOnServer(server, "docker", "compose", "-p", projectName, "down")
if err != nil {
return fmt.Errorf("docker compose down failed for project %s on remote: %v. Output: %s", projectName, err, downOutput)
}
@@ -320,11 +318,17 @@ func ComposeDownProjectRemote(projectName string, server config.AvailableServer)
// ComposePurgeProjectRemote purges a compose project by explicit -p name (shared or instanced).
func ComposePurgeProjectRemote(projectName string, server config.AvailableServer) error {
log.Debugf("Purging docker compose project %s on remote", projectName)
- purgeCommand := fmt.Sprintf("docker compose -p %s down --remove-orphans --volumes --rmi all", projectName)
- purgeOutput, err := RunCommandOnServer(server, purgeCommand)
+ purgeOutput, err := RunArgsOnServer(server, "docker", "compose", "-p", projectName, "down", "--remove-orphans", "--volumes", "--rmi", "all")
if err != nil {
return fmt.Errorf("docker compose purge failed for project %s on remote: %v. Output: %s", projectName, err, purgeOutput)
}
log.Debugf("Successfully purged compose project %s on remote. Output: %s", projectName, purgeOutput)
return nil
}
+
+func cleanupFailedComposeDeploymentRemote(projectName string, server config.AvailableServer, deploymentErr error) error {
+ if cleanupErr := ComposeDownProjectRemote(projectName, server); cleanupErr != nil {
+ return errors.Join(deploymentErr, fmt.Errorf("clean up failed remote compose deployment: %w", cleanupErr))
+ }
+ return deploymentErr
+}
diff --git a/pkg/remoteManager/exec.go b/pkg/remoteManager/exec.go
new file mode 100644
index 00000000..2fc33ad0
--- /dev/null
+++ b/pkg/remoteManager/exec.go
@@ -0,0 +1,104 @@
+package remoteManager
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+ "sync"
+
+ "github.com/sdslabs/beastv4/core/config"
+ "github.com/sdslabs/beastv4/pkg/cr"
+ "golang.org/x/crypto/ssh"
+)
+
+type cappedCommandBuffer struct {
+ buffer bytes.Buffer
+ remaining int
+ truncated bool
+ mutex sync.Mutex
+}
+
+func (buffer *cappedCommandBuffer) Write(data []byte) (int, error) {
+ buffer.mutex.Lock()
+ defer buffer.mutex.Unlock()
+ written := len(data)
+ if len(data) > buffer.remaining {
+ data = data[:buffer.remaining]
+ buffer.truncated = true
+ }
+ if len(data) > 0 {
+ _, _ = buffer.buffer.Write(data)
+ buffer.remaining -= len(data)
+ }
+ return written, nil
+}
+
+func shellQuote(value string) string {
+ if value == "" {
+ return "''"
+ }
+ return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'"
+}
+
+func shellJoin(arguments []string) string {
+ quoted := make([]string, len(arguments))
+ for index, argument := range arguments {
+ quoted[index] = shellQuote(argument)
+ }
+ return strings.Join(quoted, " ")
+}
+
+func ExecContainerRemote(ctx context.Context, server config.AvailableServer, containerID string, command []string, outputLimit int) (cr.ExecResult, error) {
+ if containerID == "" || len(command) == 0 {
+ return cr.ExecResult{}, fmt.Errorf("container ID and command are required")
+ }
+ if outputLimit <= 0 {
+ outputLimit = cr.DefaultExecOutputLimit
+ }
+ client, err := CreateSSHClient(server)
+ if err != nil {
+ return cr.ExecResult{}, err
+ }
+ defer client.Close()
+ session, err := client.NewSession()
+ if err != nil {
+ return cr.ExecResult{}, fmt.Errorf("create SSH session: %w", err)
+ }
+ defer session.Close()
+
+ stdout := &cappedCommandBuffer{remaining: (outputLimit + 1) / 2}
+ stderr := &cappedCommandBuffer{remaining: outputLimit / 2}
+ session.Stdout = stdout
+ session.Stderr = stderr
+ arguments := append([]string{"docker", "exec", "--user", "0", containerID}, command...)
+ if err := session.Start(shellJoin(arguments)); err != nil {
+ return cr.ExecResult{}, fmt.Errorf("start remote container exec: %w", err)
+ }
+ waitResult := make(chan error, 1)
+ go func() { waitResult <- session.Wait() }()
+
+ select {
+ case <-ctx.Done():
+ _ = session.Close()
+ _ = client.Close()
+ <-waitResult
+ return cr.ExecResult{}, ctx.Err()
+ case err := <-waitResult:
+ exitCode := 0
+ if err != nil {
+ var exitError *ssh.ExitError
+ if !errors.As(err, &exitError) {
+ return cr.ExecResult{}, fmt.Errorf("execute remote container command: %w", err)
+ }
+ exitCode = exitError.ExitStatus()
+ }
+ return cr.ExecResult{
+ Stdout: stdout.buffer.String(),
+ Stderr: stderr.buffer.String(),
+ ExitCode: exitCode,
+ Truncated: stdout.truncated || stderr.truncated,
+ }, nil
+ }
+}
diff --git a/pkg/remoteManager/exec_test.go b/pkg/remoteManager/exec_test.go
new file mode 100644
index 00000000..427d7582
--- /dev/null
+++ b/pkg/remoteManager/exec_test.go
@@ -0,0 +1,35 @@
+package remoteManager
+
+import "testing"
+
+func TestShellJoinQuotesEveryArgument(t *testing.T) {
+ got := shellJoin([]string{"docker", "name; touch /tmp/pwned", "it's", ""})
+ want := `'docker' 'name; touch /tmp/pwned' 'it'"'"'s' ''`
+ if got != want {
+ t.Fatalf("shellJoin() = %q, want %q", got, want)
+ }
+}
+
+func TestCappedCommandBufferDiscardsExcess(t *testing.T) {
+ buffer := &cappedCommandBuffer{remaining: 3}
+ written, err := buffer.Write([]byte("abcdef"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if written != 6 || buffer.buffer.String() != "abc" || !buffer.truncated {
+ t.Fatalf("unexpected buffer state: written=%d value=%q truncated=%t", written, buffer.buffer.String(), buffer.truncated)
+ }
+}
+
+func TestEnvironmentNamesAreConstrained(t *testing.T) {
+ for _, name := range []string{"PORT", "INSTANCE_PORT_1", "_PRIVATE"} {
+ if !environmentNamePattern.MatchString(name) {
+ t.Fatalf("valid environment name rejected: %q", name)
+ }
+ }
+ for _, name := range []string{"BAD-NAME", "NAME;id", "1PORT"} {
+ if environmentNamePattern.MatchString(name) {
+ t.Fatalf("invalid environment name accepted: %q", name)
+ }
+ }
+}
diff --git a/pkg/remoteManager/file.go b/pkg/remoteManager/file.go
index 8ffe4cd7..2e1112ca 100644
--- a/pkg/remoteManager/file.go
+++ b/pkg/remoteManager/file.go
@@ -1,32 +1,25 @@
package remoteManager
import (
- "bytes"
+ "context"
"fmt"
+ "os/exec"
"path/filepath"
"strings"
- "os/exec"
-
"github.com/sdslabs/beastv4/core"
"github.com/sdslabs/beastv4/core/config"
"github.com/sdslabs/beastv4/core/database"
+ "github.com/sdslabs/beastv4/pkg/cr"
"github.com/sdslabs/beastv4/utils"
- log "github.com/sirupsen/logrus"
)
func ValidateFileRemoteExists(server config.AvailableServer, stagedChallengePath string) error {
- output, err := RunCommandOnServer(server, fmt.Sprintf("test -e %s&&echo exists||echo not exists", stagedChallengePath))
+ _, err := RunArgsOnServer(server, "test", "-e", stagedChallengePath)
if err != nil {
- log.Errorf("Error while checking file existence: %s\n", err)
- return err
- }
- log.Printf("Output: %s\n", output)
- if strings.TrimSpace(output) == "exists" {
- return nil
- } else {
return fmt.Errorf("path %s does not exist in remote server %s", stagedChallengePath, server.Host)
}
+ return nil
}
// Rsync any file to other servers for chall deployment
@@ -36,20 +29,28 @@ func RsyncFileToServer(server config.AvailableServer, localFilePath, remoteFileP
return fmt.Errorf("file %s does not exist: %s", localFilePath, err)
}
fmt.Printf("Rsyncing %s to %s:%s\n", localFilePath, server.Host, remoteFilePath)
- cmd := exec.Command("rsync", "-avz",
- "-e", fmt.Sprintf("ssh -i %s", server.SSHKeyPath),
+ remoteShell := "ssh -i " + shellQuote(server.SSHKeyPath) +
+ " -o " + shellQuote("UserKnownHostsFile="+server.KnownHostsFile) +
+ " -o StrictHostKeyChecking=yes"
+ ctx, cancel := context.WithTimeout(context.Background(), remoteBuildTimeout)
+ defer cancel()
+ cmd := exec.CommandContext(ctx, "rsync", "-az", "--protect-args",
+ "-e", remoteShell, "--",
localFilePath,
fmt.Sprintf("%s@%s:%s", server.Username, server.Host, remoteFilePath))
- var out bytes.Buffer
- var stderr bytes.Buffer
- cmd.Stdout = &out
- cmd.Stderr = &stderr
- err = cmd.Run()
- if err != nil {
- fmt.Println(fmt.Sprint(err) + ": " + stderr.String())
- return err
+ output := &boundedCommandOutput{limit: maxRemoteCommandOutput}
+ cmd.Stdout = output
+ cmd.Stderr = output
+ commandErr := cmd.Run()
+ if ctx.Err() != nil {
+ return fmt.Errorf("rsync timed out after %s: %w", remoteBuildTimeout, ctx.Err())
+ }
+ if output.exceeded {
+ return errRemoteOutputLimit
+ }
+ if commandErr != nil {
+ return fmt.Errorf("rsync failed: %w; output: %s", commandErr, output.String())
}
- fmt.Println("Result: " + out.String())
return nil
}
@@ -62,13 +63,8 @@ func StageChallRemote(server config.AvailableServer, challenge database.Challeng
stagingDirPath := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_STAGING_DIR)
stagingRemoteDirPath := filepath.Join(core.BEAST_REMOTE_GLOBAL_DIR, core.BEAST_STAGING_DIR)
- // err = RunCommandOnServer(server, fmt.Sprintf("mkdir -p %s/%s", remoteStagingDir, challenge.Name))
- // if err != nil {
- // return fmt.Errorf("failed to create directory: %s", err)
- // }
-
// Rsync the challenge files to the server
- err = RsyncFileToServer(server, fmt.Sprintf("%s/%s", stagingDirPath, challenge.Name), stagingRemoteDirPath)
+ err = RsyncFileToServer(server, filepath.Join(stagingDirPath, challenge.Name), stagingRemoteDirPath)
if err != nil {
return fmt.Errorf("failed to rsync challenge files: %s", err)
}
@@ -77,29 +73,39 @@ func StageChallRemote(server config.AvailableServer, challenge database.Challeng
}
// BuildImageFromTarContextRemote builds a Docker image from the tar context on the remote server.
-func BuildImageFromTarContextRemote(challengeName string, imageTag string, stagedDir string, server config.AvailableServer) ([]byte, string, error) {
+func BuildImageFromTarContextRemote(challengeName string, imageTag string, stagedDir string, server config.AvailableServer, limits cr.BuildLimits) ([]byte, string, error) {
+ if err := limits.Validate(); err != nil {
+ return nil, "", fmt.Errorf("invalid build resource limits: %w", err)
+ }
remoteExtractPath := filepath.Join(core.BEAST_REMOTE_GLOBAL_DIR, core.BEAST_STAGING_DIR, challengeName, challengeName)
- _, err := RunCommandOnServer(server, fmt.Sprintf("mkdir -p %s && tar -xf %s -C %s", remoteExtractPath, stagedDir, remoteExtractPath))
+ _, err := RunArgsOnServer(server, "rm", "-rf", "--", remoteExtractPath)
+ if err == nil {
+ _, err = RunArgsOnServer(server, "mkdir", "-p", "--", remoteExtractPath)
+ }
+ if err == nil {
+ _, err = RunArgsOnServer(server, "tar", "--extract", "--gzip", "--no-same-owner", "--no-same-permissions", "--file", stagedDir, "--directory", remoteExtractPath)
+ }
if err != nil {
return []byte{}, "", fmt.Errorf("failed to extract tar: %s", err)
}
projectName := utils.ProjectNameNotInstanced(challengeName)
- dockerBuildCmd := fmt.Sprintf("cd %s && docker build -t %s "+
- "--label beast.challenge=%s "+
- "--label com.sdslabs.beast.project=%s "+
- "--label com.docker.compose.project=%s .",
- remoteExtractPath, imageTag, challengeName, projectName, projectName)
- output, err := RunCommandOnServer(server, dockerBuildCmd)
+ output, err := RunArgsInDirOnServer(server, remoteExtractPath,
+ "docker", "build", "-t", imageTag,
+ "--cpu-shares", fmt.Sprintf("%d", limits.CPUShares),
+ "--cpu-period", "100000",
+ "--cpu-quota", fmt.Sprintf("%d", cr.CPUQuota(limits.CPUs)),
+ "--memory", fmt.Sprintf("%d", limits.Memory),
+ "--memory-swap", fmt.Sprintf("%d", limits.Memory),
+ "--ulimit", fmt.Sprintf("nproc=%d:%d", limits.Pids, limits.Pids),
+ "--label", "beast.challenge="+challengeName,
+ "--label", "com.sdslabs.beast.project="+projectName,
+ "--label", "com.docker.compose.project="+projectName, ".")
if err != nil {
return []byte{}, "", fmt.Errorf("failed to build docker image: %s\nOutput: %s", err, output)
}
- getImageIDCmd := fmt.Sprintf(
- "docker images --format '{{.Repository}} {{.ID}}' | grep %s | awk '{print $2}'",
- imageTag,
- )
- imageID, err := RunCommandOnServer(server, getImageIDCmd)
+ imageID, err := RunArgsOnServer(server, "docker", "image", "inspect", "--format", "{{.Id}}", imageTag)
if err != nil {
- log.Fatalf("Failed to retrieve Docker image ID: %v", err)
+ return []byte(output), "", fmt.Errorf("retrieve Docker image ID: %w", err)
}
if imageID == "" {
return []byte{}, "", fmt.Errorf("failed to retrieve Docker image ID")
@@ -109,20 +115,20 @@ func BuildImageFromTarContextRemote(challengeName string, imageTag string, stage
func BuildImagesFromComposeRemote(challengeName, imageTag, stagedDir string, server config.AvailableServer, noCache bool) ([]byte, error) {
remoteExtractPath := filepath.Join(core.BEAST_REMOTE_GLOBAL_DIR, core.BEAST_STAGING_DIR, challengeName, challengeName)
- _, err := RunCommandOnServer(server, fmt.Sprintf("mkdir -p %s && tar -xf %s -C %s", remoteExtractPath, stagedDir, remoteExtractPath))
+ _, err := RunArgsOnServer(server, "mkdir", "-p", remoteExtractPath)
+ if err == nil {
+ _, err = RunArgsOnServer(server, "tar", "-xf", stagedDir, "-C", remoteExtractPath)
+ }
if err != nil {
return []byte{}, fmt.Errorf("failed to extract tar: %s", err)
}
- cmdBase := "docker compose build"
+ arguments := []string{"docker", "compose", "build"}
if noCache {
- cmdBase += " --no-cache"
+ arguments = append(arguments, "--no-cache")
}
// Note: docker compose build does not support --label flag
// Labels are automatically added to containers during 'docker compose up -p '
- dockerComposeBuildCmd := fmt.Sprintf("cd %s && %s", remoteExtractPath, cmdBase)
-
- // Execute the command on the remote server
- output, err := RunCommandOnServer(server, dockerComposeBuildCmd)
+ output, err := RunArgsInDirOnServer(server, remoteExtractPath, arguments...)
if err != nil {
return []byte(output), fmt.Errorf("failed to build docker compose images remotely: %s\nOutput: %s", err, output)
}
diff --git a/pkg/remoteManager/health_check.go b/pkg/remoteManager/health_check.go
index 659dd7ea..42b67d58 100644
--- a/pkg/remoteManager/health_check.go
+++ b/pkg/remoteManager/health_check.go
@@ -103,7 +103,7 @@ func CleanupOrphanedComposeInstancesOnServer(serverDeployed string) {
// getOrphanedComposeInstanceProjectsRemote returns compose instance projects on a remote server
func getOrphanedComposeInstanceProjectsRemote(server config.AvailableServer) ([]string, error) {
- output, err := RunCommandOnServer(server, "docker compose ls --format json")
+ output, err := RunArgsOnServer(server, "docker", "compose", "ls", "--format", "json")
if err != nil {
return nil, fmt.Errorf("docker compose ls failed on remote: %v", err)
}
@@ -145,8 +145,7 @@ func getOrphanedComposeInstanceProjectsRemote(server config.AvailableServer) ([]
// composeDownProjectRemote removes a docker compose project on a remote server
func composeDownProjectRemote(projectName string, server config.AvailableServer) error {
- cmd := fmt.Sprintf("docker compose -p %s down --remove-orphans -v", projectName)
- output, err := RunCommandOnServer(server, cmd)
+ output, err := RunArgsOnServer(server, "docker", "compose", "-p", projectName, "down", "--remove-orphans", "--volumes")
if err != nil {
return fmt.Errorf("docker compose down failed on remote: %v, output: %s", err, output)
}
diff --git a/pkg/remoteManager/image.go b/pkg/remoteManager/image.go
index 77fecac7..3bb845b8 100644
--- a/pkg/remoteManager/image.go
+++ b/pkg/remoteManager/image.go
@@ -1,27 +1,23 @@
package remoteManager
import (
- "fmt"
-
- "os/exec"
+ "errors"
"github.com/sdslabs/beastv4/core/config"
)
// Remove image from remote server.
func RemoveImageRemote(imageId string, server config.AvailableServer) error {
- command := fmt.Sprintf("docker rmi %s", imageId)
- _, err := RunCommandOnServer(server, command)
+ _, err := RunArgsOnServer(server, "docker", "rmi", imageId)
return err
}
// Check for existence on image on remote server
func CheckIfImageExistsOnRemote(imageId string, server config.AvailableServer) (bool, error) {
- command := fmt.Sprintf("docker inspect --format='{{.ID}}' %s", imageId)
- output, err := RunCommandOnServer(server, command)
+ output, err := RunArgsOnServer(server, "docker", "inspect", "--format", "{{.ID}}", imageId)
if err != nil {
- exitError, success := err.(*exec.ExitError)
- if success && exitError.ExitCode() == 1 {
+ var commandError *RemoteCommandError
+ if errors.As(err, &commandError) && commandError.ExitStatus == 1 {
return false, nil
}
return false, err
diff --git a/pkg/remoteManager/init.go b/pkg/remoteManager/init.go
index e4c9a43f..038cd549 100644
--- a/pkg/remoteManager/init.go
+++ b/pkg/remoteManager/init.go
@@ -1,35 +1,32 @@
package remoteManager
import (
+ "errors"
"fmt"
"path/filepath"
"github.com/sdslabs/beastv4/core"
"github.com/sdslabs/beastv4/core/config"
- log "github.com/sirupsen/logrus"
)
-func Init() {
+func Init() error {
ServerQueue = NewLoadBalancerQueue()
+ var failures []error
for serverDeployed, server := range config.Cfg.AvailableServers {
if server.Active {
// Skip SSH bootstrap for loopback workers; they use the local Docker socket from Beast.
if config.Cfg.UseLocalDockerDaemon(serverDeployed) {
continue
}
- client, err := CreateSSHClient(server)
+ _, err := RunArgsOnServer(server, "mkdir", "-p", "--", filepath.Join(core.BEAST_REMOTE_GLOBAL_DIR, core.BEAST_STAGING_DIR))
if err != nil {
- log.Errorf("SSH connection to %s failed: %s\n", server.Host, err)
+ failures = append(failures, fmt.Errorf("prepare remote %s: %w", serverDeployed, err))
continue
}
- defer client.Close()
ServerQueue.Push(server)
- _, err = RunCommandOnServer(server, fmt.Sprintf("mkdir -p %s", filepath.Join(core.BEAST_REMOTE_GLOBAL_DIR, core.BEAST_STAGING_DIR)))
- if err != nil {
- log.Errorf("failed to run command on server %s: %s", server.Host, err.Error())
- }
}
}
+ return errors.Join(failures...)
}
func Stop() {
diff --git a/pkg/remoteManager/ssh.go b/pkg/remoteManager/ssh.go
index 30871e53..3414bc02 100644
--- a/pkg/remoteManager/ssh.go
+++ b/pkg/remoteManager/ssh.go
@@ -1,16 +1,59 @@
package remoteManager
import (
+ "bytes"
"errors"
"fmt"
"io/ioutil"
+ "regexp"
+ "sort"
+ "strings"
"sync"
+ "time"
"github.com/sdslabs/beastv4/core/config"
log "github.com/sirupsen/logrus"
"golang.org/x/crypto/ssh"
+ "golang.org/x/crypto/ssh/knownhosts"
)
+const (
+ defaultRemoteCommandTimeout = 2 * time.Minute
+ remoteBuildTimeout = 30 * time.Minute
+ maxRemoteCommandOutput = 4 << 20
+)
+
+var errRemoteOutputLimit = errors.New("remote command output exceeds limit")
+
+type boundedCommandOutput struct {
+ mu sync.Mutex
+ buffer bytes.Buffer
+ limit int
+ exceeded bool
+}
+
+func (output *boundedCommandOutput) Write(data []byte) (int, error) {
+ output.mu.Lock()
+ defer output.mu.Unlock()
+ remaining := output.limit - output.buffer.Len()
+ if remaining <= 0 {
+ output.exceeded = true
+ return 0, errRemoteOutputLimit
+ }
+ if len(data) > remaining {
+ _, _ = output.buffer.Write(data[:remaining])
+ output.exceeded = true
+ return remaining, errRemoteOutputLimit
+ }
+ return output.buffer.Write(data)
+}
+
+func (output *boundedCommandOutput) String() string {
+ output.mu.Lock()
+ defer output.mu.Unlock()
+ return output.buffer.String()
+}
+
type LoadBalancerQueue struct {
servers []config.AvailableServer
mu sync.Mutex
@@ -18,6 +61,22 @@ type LoadBalancerQueue struct {
var ServerQueue LoadBalancerQueue
+var environmentNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
+
+type RemoteCommandError struct {
+ ExitStatus int
+ Output string
+ Err error
+}
+
+func (commandError *RemoteCommandError) Error() string {
+ return fmt.Sprintf("remote command exited with status %d: %v", commandError.ExitStatus, commandError.Err)
+}
+
+func (commandError *RemoteCommandError) Unwrap() error {
+ return commandError.Err
+}
+
// Returns a Queue of all available server to achive Round-Robin load balancing
func NewLoadBalancerQueue() LoadBalancerQueue {
return LoadBalancerQueue{}
@@ -71,7 +130,7 @@ func PingServer(server config.AvailableServer) error {
}
// Run the command passed as argument on the remote server
-func RunCommandOnServer(server config.AvailableServer, cmd string) (string, error) {
+func runCommandOnServer(server config.AvailableServer, cmd string, timeout time.Duration) (string, error) {
if !server.Active {
return "", fmt.Errorf("server is inactive in config.toml")
}
@@ -86,13 +145,77 @@ func RunCommandOnServer(server config.AvailableServer, cmd string) (string, erro
defer client.Close()
defer session.Close()
- output, err := session.CombinedOutput(cmd)
- if err != nil {
- return "", fmt.Errorf("failed to execute command: %s\nOutput: %s", err, output)
+ output := &boundedCommandOutput{limit: maxRemoteCommandOutput}
+ session.Stdout = output
+ session.Stderr = output
+ done := make(chan error, 1)
+ go func() {
+ done <- session.Run(cmd)
+ }()
+ timer := time.NewTimer(timeout)
+ defer timer.Stop()
+ var commandErr error
+ select {
+ case commandErr = <-done:
+ case <-timer.C:
+ _ = session.Close()
+ _ = client.Close()
+ return output.String(), fmt.Errorf("remote command timed out after %s", timeout)
+ }
+ outputText := output.String()
+ if output.exceeded {
+ return outputText, errRemoteOutputLimit
+ }
+ if commandErr != nil {
+ exitStatus := -1
+ var exitError *ssh.ExitError
+ if errors.As(commandErr, &exitError) {
+ exitStatus = exitError.ExitStatus()
+ }
+ return outputText, &RemoteCommandError{ExitStatus: exitStatus, Output: outputText, Err: commandErr}
+ }
+
+ log.Debugf("Remote command completed on %s with %d output bytes", server.Host, len(outputText))
+ return outputText, nil
+}
+
+func RunArgsOnServer(server config.AvailableServer, arguments ...string) (string, error) {
+ if len(arguments) == 0 {
+ return "", fmt.Errorf("remote command arguments are empty")
+ }
+ return runCommandOnServer(server, "exec "+shellJoin(arguments), defaultRemoteCommandTimeout)
+}
+
+func RunArgsInDirOnServer(server config.AvailableServer, directory string, arguments ...string) (string, error) {
+ if directory == "" || len(arguments) == 0 {
+ return "", fmt.Errorf("remote directory and command arguments are required")
}
+ command := "cd -- " + shellQuote(directory) + " && exec " + shellJoin(arguments)
+ return runCommandOnServer(server, command, remoteBuildTimeout)
+}
- log.Debugf("Command output for cmd %s : %s\n", cmd, output)
- return string(output), nil
+func RunArgsWithEnvOnServer(server config.AvailableServer, environment map[string]string, arguments ...string) (string, error) {
+ if len(arguments) == 0 {
+ return "", fmt.Errorf("remote command arguments are empty")
+ }
+ keys := make([]string, 0, len(environment))
+ for key := range environment {
+ if !environmentNamePattern.MatchString(key) {
+ return "", fmt.Errorf("invalid environment variable name %q", key)
+ }
+ keys = append(keys, key)
+ }
+ sort.Strings(keys)
+ assignments := make([]string, 0, len(keys))
+ for _, key := range keys {
+ assignments = append(assignments, key+"="+shellQuote(environment[key]))
+ }
+ command := strings.Join(assignments, " ")
+ if command != "" {
+ command += " "
+ }
+ command += "exec " + shellJoin(arguments)
+ return runCommandOnServer(server, command, defaultRemoteCommandTimeout)
}
// Creates an SSH client to connect to the remote server.
@@ -100,6 +223,10 @@ func CreateSSHClient(remoteServer config.AvailableServer) (*ssh.Client, error) {
if !remoteServer.Active {
return nil, fmt.Errorf("server is inactive in config.toml")
}
+ hostKeyCallback, err := knownhosts.New(remoteServer.KnownHostsFile)
+ if err != nil {
+ return nil, fmt.Errorf("load known_hosts file: %s", err)
+ }
key, err := ioutil.ReadFile(remoteServer.SSHKeyPath)
if err != nil {
return nil, fmt.Errorf("unable to read private key: %s", err)
@@ -109,13 +236,13 @@ func CreateSSHClient(remoteServer config.AvailableServer) (*ssh.Client, error) {
if err != nil {
return nil, fmt.Errorf("unable to parse private key: %s", err)
}
-
config := &ssh.ClientConfig{
User: remoteServer.Username,
Auth: []ssh.AuthMethod{
ssh.PublicKeys(signer),
},
- HostKeyCallback: ssh.InsecureIgnoreHostKey(), // TODO: Insecure for now. Integrate proper callback for host key verification.
+ HostKeyCallback: hostKeyCallback,
+ Timeout: 10 * time.Second,
}
client, err := ssh.Dial("tcp", fmt.Sprintf("%s:22", remoteServer.Host), config)
diff --git a/pkg/remoteManager/ssh_output_test.go b/pkg/remoteManager/ssh_output_test.go
new file mode 100644
index 00000000..dec9e0a0
--- /dev/null
+++ b/pkg/remoteManager/ssh_output_test.go
@@ -0,0 +1,17 @@
+package remoteManager
+
+import (
+ "errors"
+ "testing"
+)
+
+func TestBoundedCommandOutputStopsAtLimit(t *testing.T) {
+ output := &boundedCommandOutput{limit: 4}
+ written, err := output.Write([]byte("abcdef"))
+ if written != 4 || !errors.Is(err, errRemoteOutputLimit) {
+ t.Fatalf("write = %d, %v", written, err)
+ }
+ if got := output.String(); got != "abcd" || !output.exceeded {
+ t.Fatalf("output = %q, exceeded = %v", got, output.exceeded)
+ }
+}
diff --git a/pkg/remoteManager/ssh_test.go b/pkg/remoteManager/ssh_test.go
new file mode 100644
index 00000000..b2cc2a80
--- /dev/null
+++ b/pkg/remoteManager/ssh_test.go
@@ -0,0 +1,27 @@
+package remoteManager
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/sdslabs/beastv4/core/config"
+)
+
+func TestCreateSSHClientRejectsMissingKnownHosts(t *testing.T) {
+ keyFile := filepath.Join(t.TempDir(), "key")
+ if err := os.WriteFile(keyFile, []byte("not-a-key"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ server := config.AvailableServer{
+ Active: true,
+ SSHKeyPath: keyFile,
+ KnownHostsFile: filepath.Join(t.TempDir(), "missing"),
+ }
+
+ _, err := CreateSSHClient(server)
+ if err == nil || !strings.Contains(err.Error(), "known_hosts") {
+ t.Fatalf("expected SSH configuration error, got %v", err)
+ }
+}
diff --git a/pkg/scheduler/scheduler.go b/pkg/scheduler/scheduler.go
index 6e46558c..97c6d7cb 100644
--- a/pkg/scheduler/scheduler.go
+++ b/pkg/scheduler/scheduler.go
@@ -1,42 +1,62 @@
package scheduler
import (
+ "fmt"
"sync"
"time"
+
+ log "github.com/sirupsen/logrus"
)
type Scheduler struct {
Tasks TaskMap
FuncRegister TaskFunctionRegister
- stopChan chan bool
- ticker *time.Ticker
+ stopChan chan struct{}
+ done chan struct{}
+ interval time.Duration
- once sync.Once
+ once sync.Once
+ mu sync.Mutex
+ started bool
+ stopped bool
+ running map[TaskID]bool
+ taskRuns sync.WaitGroup
}
func NewScheduler() Scheduler {
+ return newScheduler(time.Second)
+}
+
+func newScheduler(interval time.Duration) Scheduler {
return Scheduler{
Tasks: NewTaskMap(),
FuncRegister: NewTaskFunctionRegister(),
-
- stopChan: make(chan bool),
- ticker: time.NewTicker(1 * time.Second),
-
- once: sync.Once{},
+ stopChan: make(chan struct{}),
+ done: make(chan struct{}),
+ interval: interval,
+ running: make(map[TaskID]bool),
}
}
func (scheduler *Scheduler) Start() {
+ scheduler.mu.Lock()
+ if scheduler.started || scheduler.stopped {
+ scheduler.mu.Unlock()
+ return
+ }
+ scheduler.started = true
+ scheduler.mu.Unlock()
+
go func() {
+ ticker := time.NewTicker(scheduler.interval)
+ defer ticker.Stop()
+ defer close(scheduler.done)
for {
select {
- case <-scheduler.ticker.C:
+ case <-ticker.C:
scheduler.runPending()
case <-scheduler.stopChan:
- scheduler.ticker.Stop()
- close(scheduler.stopChan)
-
return
}
}
@@ -45,15 +65,29 @@ func (scheduler *Scheduler) Start() {
func (Scheduler *Scheduler) Stop() {
Scheduler.once.Do(func() {
- Scheduler.stopChan <- true
+ Scheduler.mu.Lock()
+ Scheduler.stopped = true
+ started := Scheduler.started
+ close(Scheduler.stopChan)
+ if !started {
+ close(Scheduler.done)
+ }
+ Scheduler.mu.Unlock()
})
+ <-Scheduler.done
+ Scheduler.taskRuns.Wait()
}
func (Scheduler *Scheduler) Wait() {
- <-Scheduler.stopChan
+ <-Scheduler.done
}
func (scheduler *Scheduler) ScheduleAt(time time.Time, function Function, params ...FuncParam) error {
+ scheduler.mu.Lock()
+ defer scheduler.mu.Unlock()
+ if scheduler.stopped {
+ return fmt.Errorf("scheduler is stopped")
+ }
funcID, err := scheduler.FuncRegister.AddFunction(function, params...)
if err != nil {
return err
@@ -73,6 +107,14 @@ func (scheduler *Scheduler) ScheduleAfter(duration time.Duration, function Funct
}
func (scheduler *Scheduler) ScheduleEvery(duration time.Duration, function Function, params ...FuncParam) error {
+ if duration <= 0 {
+ return fmt.Errorf("schedule duration must be positive")
+ }
+ scheduler.mu.Lock()
+ defer scheduler.mu.Unlock()
+ if scheduler.stopped {
+ return fmt.Errorf("scheduler is stopped")
+ }
funcID, err := scheduler.FuncRegister.AddFunction(function, params...)
if err != nil {
return err
@@ -89,18 +131,36 @@ func (scheduler *Scheduler) ScheduleEvery(duration time.Duration, function Funct
}
func (scheduler *Scheduler) runPending() {
+ scheduler.mu.Lock()
+ now := time.Now()
for id, task := range scheduler.Tasks {
- if task.IsDue() {
+ if task.IsDue() && !scheduler.running[id] {
if function, ok := scheduler.FuncRegister.Functions[task.FunctionID]; ok {
- go function.Run()
+ scheduler.running[id] = true
+ scheduler.taskRuns.Add(1)
+ go scheduler.runTask(id, function)
}
if !task.Schedule.IsRecurring {
delete(scheduler.Tasks, id)
} else {
- task.Schedule.LastRun = time.Now()
- task.Schedule.NextRun = task.Schedule.NextRun.Add(task.Schedule.Duration)
+ task.Schedule.LastRun = now
+ task.Schedule.NextRun = now.Add(task.Schedule.Duration)
}
}
}
+ scheduler.mu.Unlock()
+}
+
+func (scheduler *Scheduler) runTask(id TaskID, function TaskFunction) {
+ defer scheduler.taskRuns.Done()
+ defer func() {
+ if recovered := recover(); recovered != nil {
+ log.Errorf("scheduled task panicked: %v", recovered)
+ }
+ scheduler.mu.Lock()
+ delete(scheduler.running, id)
+ scheduler.mu.Unlock()
+ }()
+ function.Run()
}
diff --git a/pkg/scheduler/scheduler_test.go b/pkg/scheduler/scheduler_test.go
new file mode 100644
index 00000000..2e9d926f
--- /dev/null
+++ b/pkg/scheduler/scheduler_test.go
@@ -0,0 +1,60 @@
+package scheduler
+
+import (
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+func TestSchedulerRejectsNonPositiveRecurringDuration(t *testing.T) {
+ scheduler := newScheduler(time.Millisecond)
+ if err := scheduler.ScheduleEvery(0, func() {}); err == nil {
+ t.Fatal("expected invalid duration error")
+ }
+ scheduler.Stop()
+}
+
+func TestSchedulerDoesNotOverlapRecurringTask(t *testing.T) {
+ scheduler := newScheduler(time.Millisecond)
+ var running atomic.Int32
+ var maximum atomic.Int32
+ task := func() {
+ current := running.Add(1)
+ for {
+ old := maximum.Load()
+ if current <= old || maximum.CompareAndSwap(old, current) {
+ break
+ }
+ }
+ time.Sleep(10 * time.Millisecond)
+ running.Add(-1)
+ }
+ if err := scheduler.ScheduleEvery(time.Millisecond, task); err != nil {
+ t.Fatal(err)
+ }
+ scheduler.Start()
+ time.Sleep(30 * time.Millisecond)
+ scheduler.Stop()
+ if maximum.Load() != 1 {
+ t.Fatalf("maximum concurrent executions = %d, want 1", maximum.Load())
+ }
+}
+
+func TestSchedulerSupportsConcurrentScheduling(t *testing.T) {
+ scheduler := newScheduler(time.Millisecond)
+ scheduler.Start()
+ var group sync.WaitGroup
+ for i := 0; i < 20; i++ {
+ group.Add(1)
+ go func() {
+ defer group.Done()
+ if err := scheduler.ScheduleAfter(time.Millisecond, func() {}); err != nil {
+ t.Errorf("schedule task: %v", err)
+ }
+ }()
+ }
+ group.Wait()
+ time.Sleep(5 * time.Millisecond)
+ scheduler.Stop()
+}
diff --git a/pkg/workerpool/pool.go b/pkg/workerpool/pool.go
index 4028c8e6..016a9be8 100644
--- a/pkg/workerpool/pool.go
+++ b/pkg/workerpool/pool.go
@@ -14,6 +14,26 @@ type Queue struct {
InQueue map[string]bool // A map which stores if the task related to some id is already in the queue
CompletionChannel chan bool
+ stopChannel chan struct{}
+ stopOnce sync.Once
+ workers sync.WaitGroup
+ stopped bool
+ errors []error
+}
+
+func (q *Queue) RecordError(err error) {
+ if err == nil {
+ return
+ }
+ q.Mux.Lock()
+ defer q.Mux.Unlock()
+ q.errors = append(q.errors, err)
+}
+
+func (q *Queue) Errors() []error {
+ q.Mux.RLock()
+ defer q.Mux.RUnlock()
+ return append([]error(nil), q.errors...)
}
type Task struct {
@@ -27,16 +47,19 @@ type Worker interface {
func (q *Queue) Push(w Task) error {
q.Mux.Lock()
+ defer q.Mux.Unlock()
+ if q.stopped {
+ return fmt.Errorf("queue is stopped")
+ }
if _, ex := q.InQueue[w.ID]; ex {
- q.Mux.Unlock()
log.Warnf("The Task ID : %s is already in queue", w.ID)
return fmt.Errorf("The Task ID : %s is already in queue", w.ID)
}
q.InQueue[w.ID] = true
- q.Mux.Unlock()
select {
case q.TaskQueue <- w:
default:
+ delete(q.InQueue, w.ID)
return fmt.Errorf("Queue is full")
}
// TODO : get size of the queue
@@ -46,34 +69,46 @@ func (q *Queue) Push(w Task) error {
func (q *Queue) Pop(ID string) {
q.Mux.Lock()
delete(q.InQueue, ID)
- if q.CompletionChannel != nil && len(q.InQueue) == 0 {
- q.CompletionChannel <- true
- }
+ completed := q.CompletionChannel != nil && len(q.InQueue) == 0
q.Mux.Unlock()
+ if completed {
+ select {
+ case q.CompletionChannel <- true:
+ default:
+ }
+ }
}
func (q *Queue) Stop() {
- ids := make([]string, len(q.InQueue))
- i := 0
- for id := range q.InQueue {
- ids[i] = id
- i++
- }
+ q.stopOnce.Do(func() {
+ q.Mux.Lock()
+ q.stopped = true
+ close(q.stopChannel)
+ q.Mux.Unlock()
+ q.workers.Wait()
- for _, id := range ids {
- q.Pop(id)
- }
+ q.Mux.Lock()
+ for id := range q.InQueue {
+ delete(q.InQueue, id)
+ }
+ q.Mux.Unlock()
+ })
}
func (q *Queue) startConcurrentWorker(i int, worker Worker) {
+ defer q.workers.Done()
for {
- w := <-q.TaskQueue
- newTask := worker.PerformTask(w)
+ select {
+ case <-q.stopChannel:
+ return
+ case w := <-q.TaskQueue:
+ newTask := worker.PerformTask(w)
- q.Pop(w.ID)
+ q.Pop(w.ID)
- if newTask != nil {
- q.Push(*newTask)
+ if newTask != nil {
+ _ = q.Push(*newTask)
+ }
}
}
}
@@ -81,6 +116,7 @@ func (q *Queue) startConcurrentWorker(i int, worker Worker) {
func (q *Queue) StartWorkers(worker Worker) {
numCPUs := runtime.NumCPU()
log.Info("Total Workers: ", numCPUs)
+ q.workers.Add(numCPUs)
for i := 0; i < numCPUs; i++ {
go q.startConcurrentWorker(i, worker)
}
@@ -93,6 +129,7 @@ func InitQueue(maxQueueSize uint32, completionChannel chan bool) *Queue {
Mux: sync.RWMutex{},
InQueue: map[string]bool{},
CompletionChannel: completionChannel,
+ stopChannel: make(chan struct{}),
}
return Q
}
diff --git a/pkg/workerpool/pool_test.go b/pkg/workerpool/pool_test.go
new file mode 100644
index 00000000..4f34a4e3
--- /dev/null
+++ b/pkg/workerpool/pool_test.go
@@ -0,0 +1,66 @@
+package Taskerpool
+
+import (
+ "fmt"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+type testWorker struct {
+ count atomic.Int32
+}
+
+func (worker *testWorker) PerformTask(Task) *Task {
+ worker.count.Add(1)
+ return nil
+}
+
+func TestPushRollsBackFullQueueMarker(t *testing.T) {
+ queue := InitQueue(1, nil)
+ if err := queue.Push(Task{ID: "first"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := queue.Push(Task{ID: "second"}); err == nil {
+ t.Fatal("expected full queue error")
+ }
+ queue.Mux.RLock()
+ _, marked := queue.InQueue["second"]
+ queue.Mux.RUnlock()
+ if marked {
+ t.Fatal("failed task remained marked in queue")
+ }
+ queue.Stop()
+}
+
+func TestStopTerminatesWorkersAndRejectsTasks(t *testing.T) {
+ queue := InitQueue(1, nil)
+ worker := &testWorker{}
+ queue.StartWorkers(worker)
+ if err := queue.Push(Task{ID: "task"}); err != nil {
+ t.Fatal(err)
+ }
+
+ deadline := time.Now().Add(time.Second)
+ for worker.count.Load() == 0 && time.Now().Before(deadline) {
+ time.Sleep(time.Millisecond)
+ }
+ queue.Stop()
+ if err := queue.Push(Task{ID: "after-stop"}); err == nil {
+ t.Fatal("expected stopped queue error")
+ }
+}
+
+func TestQueueRecordsErrorsSafely(t *testing.T) {
+ queue := InitQueue(1, nil)
+ want := fmt.Errorf("task failed")
+ queue.RecordError(want)
+ errors := queue.Errors()
+ if len(errors) != 1 || errors[0] != want {
+ t.Fatalf("Errors() = %v, want [%v]", errors, want)
+ }
+ errors[0] = nil
+ if queue.Errors()[0] != want {
+ t.Fatal("Errors returned internal queue storage")
+ }
+}
diff --git a/requirements.txt b/requirements.txt
index 085dd2ea..9617821c 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,3 +1,3 @@
-mkdocs
-mkdocs-bootswatch
-PyYAML
+mkdocs==1.6.1
+mkdocs-bootswatch==1.1
+PyYAML==6.0.2
diff --git a/scripts/build/build.sh b/scripts/build/build.sh
index 0bfb6a8a..0dc36edc 100755
--- a/scripts/build/build.sh
+++ b/scripts/build/build.sh
@@ -1,25 +1,20 @@
-#!/bin/bash
+#!/usr/bin/env bash
# Build Beast
-# Exit if any steps fail
-set -e
-
-CWD=${PWD}
+set -euo pipefail
GO_FLAGS=${GO_FLAGS:-"-tags netgo"}
GO_CMD=${GO_CMD:-"build"}
-BUILD_USER=${BUILD_USER:-"${USER}@${HOSTNAME}"}
-BUILD_DATE=${BUILD_DATE:-$( date +%Y%m%d-%H:%M:%S )}
+BUILD_USER=${BUILD_USER:-"${USER:-unknown}@${HOSTNAME:-unknown}"}
+BUILD_DATE=${BUILD_DATE:-$(date -u +%Y%m%d-%H:%M:%S)}
VERBOSE=${VERBOSE:-}
+OUTPUT=${BEAST_OUTPUT:-"$(go env GOPATH)/bin/beast"}
repo_path="github.com/sdslabs/beastv4"
main_package="github.com/sdslabs/beastv4/cmd/beast"
-mysql_agent="github.com/sdslabs/beastv4/cmd/agents/mysql"
-mongo_agent="github.com/sdslabs/beastv4/cmd/agents/mongo"
-
# Get branch revision and version
-version="0.1"
+version="0.2"
revision=$(git rev-parse --short HEAD 2> /dev/null || echo 'unknown')
branch=$(git rev-parse --abbrev-ref HEAD 2> /dev/null || echo 'unknown')
@@ -27,21 +22,13 @@ branch=$(git rev-parse --abbrev-ref HEAD 2> /dev/null || echo 'unknown')
go_version=$(go version | sed -e 's/^[^0-9.]*\([0-9.]*\).*/\1/')
-# go 1.4 requires ldflags format to be "-X key value", not "-X key=value"
-# ldseparator here is for cross compatibility between go versions
-
-ldseparator="="
-if [ "${go_version:0:3}" = "1.4" ]; then
- ldseparator=" "
-fi
-
ldflags="
- -X ${repo_path}/version.Version${ldseparator}${version}
- -X ${repo_path}/version.Revision${ldseparator}${revision}
- -X ${repo_path}/version.Branch${ldseparator}${branch}
- -X ${repo_path}/version.BuildUser${ldseparator}${BUILD_USER}
- -X ${repo_path}/version.BuildDate${ldseparator}${BUILD_DATE}
- -X ${repo_path}/version.GoVersion${ldseparator}${go_version}"
+ -X ${repo_path}/version.Version=${version}
+ -X ${repo_path}/version.Revision=${revision}
+ -X ${repo_path}/version.Branch=${branch}
+ -X ${repo_path}/version.BuildUser=${BUILD_USER}
+ -X ${repo_path}/version.BuildDate=${BUILD_DATE}
+ -X ${repo_path}/version.GoVersion=${go_version}"
echo ">>> Building Beast..."
@@ -49,7 +36,9 @@ if [ -n "$VERBOSE" ]; then
echo "Building with -ldflags $ldflags"
fi
-GOBIN=$PWD go "${GO_CMD}" -o "${GOPATH}/bin/beast" ${GO_FLAGS} -ldflags "${ldflags}" "${main_package}"
+mkdir -p "$(dirname "${OUTPUT}")"
+# GO_FLAGS is intentionally word-split to preserve the existing override interface.
+# shellcheck disable=SC2086
+go "${GO_CMD}" -o "${OUTPUT}" ${GO_FLAGS} -ldflags "${ldflags}" "${main_package}"
-echo "[*] Build Complete."
-exit 0
+echo "[*] Build complete: ${OUTPUT}"
diff --git a/scripts/build/check_gofmt.sh b/scripts/build/check_gofmt.sh
index 778f3e36..6a368beb 100755
--- a/scripts/build/check_gofmt.sh
+++ b/scripts/build/check_gofmt.sh
@@ -1,14 +1,12 @@
-#!/bin/bash
+#!/usr/bin/env bash
-# Check the errors in formatting using gofmt
-# Check formatting on non Godep'd code.
-GOFMT_PATHS=$(find . -not -wholename "*.git*" -not -wholename "*Godeps*" -not -wholename "*gopath*" -not -wholename "*vendor*" -not -name "." -type d)
+set -euo pipefail
-# Find any files with gofmt problems
-BAD_FILES=$(gofmt -s -l $GOFMT_PATHS)
+mapfile -t go_files < <(git ls-files '*.go')
+bad_files=$(gofmt -s -l "${go_files[@]}")
-if [ -n "$BAD_FILES" ]; then
+if [[ -n "${bad_files}" ]]; then
echo "The following files are not properly formatted:"
- echo $BAD_FILES
+ printf '%s\n' "${bad_files}"
exit 1
fi
diff --git a/scripts/build/extras.sh b/scripts/build/extras.sh
index d1b22de2..27069bc2 100755
--- a/scripts/build/extras.sh
+++ b/scripts/build/extras.sh
@@ -1,28 +1,8 @@
-#!/bin/bash
+#!/usr/bin/env bash
-set -euxo pipefail
+set -euo pipefail
-CWD=$PWD
-
-BEAST_STATIC_PORT=8034
-
-echo -e "\nBuilding static content server for beast...\n"
-cd "${CWD}/extras/static-content"
-
-if docker images | grep -q 'beast-static'; then
- echo "Image for static-content already exists."
-else
- docker build . --tag beast-static:latest
-fi
-
-if docker ps -a | grep -q 'beast-static'; then
- echo "Container for static-content already exists."
-else
- docker run -d -p $BEAST_STATIC_PORT:80 \
- -v ~/.beast/staging:/beast \
- -v ~/.beast/.static.beast.htpasswd:/.static.beast.htpasswd \
- beast-static
-fi
-
-echo "Extras build script complete."
+repo_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
+docker build --pull --tag beast-static:latest "${repo_dir}/extras/static-content"
+echo "Built beast-static:latest. Deploy it through the authenticated Beast management API."
diff --git a/scripts/docker-enter b/scripts/docker-enter
deleted file mode 100755
index 26e23996..00000000
--- a/scripts/docker-enter
+++ /dev/null
@@ -1,52 +0,0 @@
-#!/bin/sh
-# This file is an almost fork of https://github.com/jpetazzo/nsenter/blob/master/docker-enter
-
-if [ -e $(dirname "$0")/nsenter ]; then
- # with boot2docker, nsenter is not in the PATH but it is in the same folder
- NSENTER=$(dirname "$0")/nsenter
-else
- NSENTER=nsenter
-fi
-
-if [ -e $(dirname "$0")/importenv ]; then
- # with boot2docker, importenv is not in the PATH but it is in the same folder
- IMPORTENV=$(dirname "$0")/importenv
-else
- IMPORTENV=importenv
-fi
-
-if [ -z "$1" ]; then
- echo "Usage: `basename "$0"` CONTAINER [COMMAND [ARG]...]"
- echo ""
- echo "Enters the Docker CONTAINER and executes the specified COMMAND."
- echo "If COMMAND is not specified, runs an interactive shell in CONTAINER."
- exit
-fi
-
-PID=$(docker inspect --format "{{.State.Pid}}" "$1")
-[ -z "$PID" ] && exit 1
-shift
-
-if [ "$(id -u)" -ne "0" ]; then
- which sudo > /dev/null
- if [ "$?" -eq "0" ]; then
- LAZY_SUDO="sudo "
- else
- echo "Warning: Cannot find sudo; Invoking nsenter as the user $USER." >&2
- fi
-fi
-
-ENVIRON="/proc/$PID/environ"
-
-# Prepare nsenter flags
-OPTS="--target $PID --mount --uts --ipc --net --pid --"
-
-# env is to clear all host environment variables and set then anew
-if [ $# -lt 1 ]; then
- # No arguments, default to `su` which executes the default login shell
- $LAZY_SUDO "$IMPORTENV" "$ENVIRON" "$NSENTER" $OPTS su -m root
-else
- # Has command
- # "$@" is magic in bash, and needs to be in the invocation
- $LAZY_SUDO "$IMPORTENV" "$ENVIRON" "$NSENTER" $OPTS "$@"
-fi
diff --git a/scripts/docker_enter b/scripts/docker_enter
deleted file mode 100755
index eb5caf68..00000000
Binary files a/scripts/docker_enter and /dev/null differ
diff --git a/scripts/importenv.c b/scripts/importenv.c
deleted file mode 100644
index 197ac482..00000000
--- a/scripts/importenv.c
+++ /dev/null
@@ -1,49 +0,0 @@
-// This is a fork of https://github.com/jpetazzo/nsenter/blob/master/importenv.c
-#define _GNU_SOURCE
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-
-const int MAX_ENV_SIZE = 1024*1024;
-const int MAX_ENV_VARS = 1024;
-
-int main (int argc, char* argv[]) {
- if (argc < 3) {
- printf("Syntax: %s [args...]\n", argv[0]);
- exit(1);
- }
- int fd = open(argv[1], O_RDONLY);
- if (-1 == fd) {
- perror("open");
- exit(1);
- }
- char env[MAX_ENV_SIZE+2];
- int env_size = read(fd, env, MAX_ENV_SIZE);
- if (-1 == env_size) {
- perror("read");
- exit(1);
- }
- if (MAX_ENV_SIZE == env_size) {
- printf("WARNING: environment bigger than %d bytes. It has been truncated.\n", MAX_ENV_SIZE);
- }
- char* envp[MAX_ENV_VARS];
- int i;
- char *c;
- for (i=0, c=env; i&2; exit 2 ;;
+esac
+
+required_commands=(go docker git make)
+missing=()
+for command in "${required_commands[@]}"; do
+ if ! command -v "${command}" >/dev/null 2>&1; then
+ missing+=("${command}")
+ fi
+done
+if ((${#missing[@]} > 0)); then
+ echo "missing required commands: ${missing[*]}" >&2
+ echo "install them with your operating system's trusted package manager" >&2
+ exit 1
+fi
+
+go version
+docker version --format '{{.Client.Version}}' >/dev/null
+if ! docker info >/dev/null 2>&1; then
+ echo "Docker is installed but the daemon is unavailable to the current user" >&2
+ exit 1
+fi
+
+if [[ "${install_dev_tools}" == true ]]; then
+ echo "installing pinned Air development tool"
+ go install github.com/air-verse/air@v1.61.7
+fi
+
+echo "Beast prerequisites are available"
diff --git a/scripts/provision/dependencies.sh b/scripts/provision/dependencies.sh
new file mode 100755
index 00000000..f41e18e3
--- /dev/null
+++ b/scripts/provision/dependencies.sh
@@ -0,0 +1,21 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+readonly go_version="1.23.12"
+readonly go_sha256="d3847fef834e9db11bf64e3fb34db9c04db14e068eeb064f49af747010454f90"
+
+export DEBIAN_FRONTEND=noninteractive
+apt-get update
+apt-get install --yes --no-install-recommends ca-certificates curl git make
+
+archive=$(mktemp)
+trap 'rm -f "${archive}"' EXIT
+curl --fail --location --proto '=https' --tlsv1.2 \
+ "https://dl.google.com/go/go${go_version}.linux-amd64.tar.gz" \
+ --output "${archive}"
+printf '%s %s\n' "${go_sha256}" "${archive}" | sha256sum --check --status
+
+rm -rf /usr/local/go
+tar --extract --gzip --file "${archive}" --directory /usr/local
+ln -sfn /usr/local/go/bin/go /usr/local/bin/go
diff --git a/scripts/provision/setup.sh b/scripts/provision/setup.sh
old mode 100644
new mode 100755
index 907dd6e0..1d3ac32c
--- a/scripts/provision/setup.sh
+++ b/scripts/provision/setup.sh
@@ -1,51 +1,6 @@
-#!/bin/bash
+#!/usr/bin/env bash
-echo -e "Setting up sample environment for beast..."
-
-# Creating required directories
-mkdir -p "$HOME/.beast" "$HOME/.beast/assets/logo" "$HOME/.beast/remote" "$HOMER/.beast/uploads" "$HOME/.beast/secrets" "$HOME/.beast/scripts" "$HOME/.beast/staging"
-
-# Creating random authorized_keys and secret.key files
-echo -e "auth_keys" > $HOME/.beast/authorized_keys
-echo -e "auth_keys" > $HOME/.beast/secret.key
-
-# Set beast folder location in vagant box
-BEAST_FOLDER=$HOME/beast
-
-BEAST_GLOBAL_CONFIG=$HOME/.beast/config.toml
-EXAMPLE_CONFIG_FILE=$BEAST_FOLDER/_examples/example.config.toml
-
-if [ -f "$BEAST_GLOBAL_CONFIG" ]; then
- echo -e "Found $BEAST_GLOBAL_CONFIG"
-else
- if [ -f "$EXAMPLE_CONFIG_FILE" ]; then
- echo -e "Copying example config file"
- cp $BEAST_FOLDER/_examples/example.config.toml $BEAST_GLOBAL_CONFIG
- else
- echo -e '\e[93mCould not find example.config.toml'
- echo -e 'Downloading example.config.toml'
- wget https://raw.githubusercontent.com/sdslabs/beast/master/_examples/example.config.toml
- cp ./example.config.toml $BEAST_GLOBAL_CONFIG
- exit
- fi
- sed -i "s/vsts/$USER/g" $BEAST_GLOBAL_CONFIG
-fi
-
-echo -e "Created .beast folder..."
-
-echo -e "Building beast..."
-
-export GO111MODULES=on
-
-echo -e 'checking if docker is running...'
-# Checking if docker deamon is running or not by checking its PID
-DOCKER_PID_FILE=/var/run/docker.pid
-if [ -f "$DOCKER_PID_FILE" ]; then
- echo -e "Docker is running."
-else
- echo -e '\e[31mDocker daemon is not running'
- echo -e '\e[31mAborting...'
- echo -e "\e[31mPlease start docker daemon and restart again"
- exit
-fi
+set -euo pipefail
+repo_dir=${BEAST_REPOSITORY:-"${HOME}/beast"}
+exec "${repo_dir}/setup.sh"
diff --git a/scripts/teardown.sh b/scripts/teardown.sh
new file mode 100755
index 00000000..e2d17a3a
--- /dev/null
+++ b/scripts/teardown.sh
@@ -0,0 +1,61 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+purge_data=false
+case "${1:-}" in
+ "") ;;
+ --purge-data) purge_data=true ;;
+ *) echo "usage: $0 [--purge-data]" >&2; exit 2 ;;
+esac
+
+beast_dir=${BEAST_HOME:-"${HOME}/.beast"}
+lock_file="${beast_dir}/controller.lock"
+
+if [[ -f "${lock_file}" ]]; then
+ if ! command -v flock >/dev/null 2>&1; then
+ echo "flock is required to verify the Beast controller lock" >&2
+ exit 1
+ fi
+ exec 9<>"${lock_file}"
+ if flock --nonblock 9; then
+ flock --unlock 9
+ else
+ read -r controller_pid <&9 || true
+ if [[ ! "${controller_pid:-}" =~ ^[0-9]+$ ]] || ! kill -0 "${controller_pid}" 2>/dev/null; then
+ echo "controller lock is held but its PID is invalid; data was not removed" >&2
+ exit 1
+ fi
+ executable=$(readlink -f "/proc/${controller_pid}/exe" 2>/dev/null || true)
+ if [[ $(basename -- "${executable}") != beast ]]; then
+ echo "refusing to signal PID ${controller_pid}: it is not a Beast process" >&2
+ exit 1
+ fi
+ if [[ $(stat -c '%u' "/proc/${controller_pid}" 2>/dev/null || true) != "$(id -u)" ]]; then
+ echo "refusing to signal PID ${controller_pid}: it belongs to another user" >&2
+ exit 1
+ fi
+ kill -TERM "${controller_pid}"
+ for _ in {1..60}; do
+ if ! kill -0 "${controller_pid}" 2>/dev/null; then
+ break
+ fi
+ sleep 1
+ done
+ if kill -0 "${controller_pid}" 2>/dev/null; then
+ echo "Beast did not stop within 60 seconds; data was not removed" >&2
+ exit 1
+ fi
+ fi
+fi
+
+if [[ "${purge_data}" == true ]]; then
+ if [[ -z "${beast_dir}" || "${beast_dir}" == / || "${beast_dir}" == "${HOME}" ]]; then
+ echo "refusing unsafe Beast data path: ${beast_dir}" >&2
+ exit 1
+ fi
+ rm -rf -- "${beast_dir}"
+ echo "Removed Beast local data at ${beast_dir}. External PostgreSQL and Redis data were not removed."
+else
+ echo "Beast is stopped. Use --purge-data to remove ${beast_dir}."
+fi
diff --git a/scripts/test/backend_submit_race.sh b/scripts/test/backend_submit_race.sh
index b17a1b0b..edb213fc 100755
--- a/scripts/test/backend_submit_race.sh
+++ b/scripts/test/backend_submit_race.sh
@@ -1,345 +1,16 @@
#!/usr/bin/env bash
-set -euo pipefail
-
-ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
-
-PGHOST="${BEAST_TEST_PGHOST:-localhost}"
-PGPORT="${BEAST_TEST_PGPORT:-55543}"
-PGUSER="${BEAST_TEST_PGUSER:-beasttest}"
-PGPASSWORD="${BEAST_TEST_PGPASSWORD:-beasttest}"
-PGDATABASE="${BEAST_TEST_PGDATABASE:-beast_backend_test}"
-REDIS_HOST="${BEAST_TEST_REDIS_HOST:-localhost}"
-REDIS_PORT="${BEAST_TEST_REDIS_PORT:-56380}"
-SERVER_PORT="${BEAST_TEST_SERVER_PORT:-5505}"
-TEST_HOME="${BEAST_TEST_HOME:-/tmp/beast-backend-submit-race}"
-LOG_FILE="$TEST_HOME/beast-api.log"
-BASE_URL="http://localhost:$SERVER_PORT"
-
-export PGPASSWORD
-
-cleanup() {
- local status=$?
- if [[ -n "${SERVER_PID:-}" ]] && kill -0 "$SERVER_PID" 2>/dev/null; then
- kill "$SERVER_PID" 2>/dev/null || true
- wait "$SERVER_PID" 2>/dev/null || true
- fi
- exit "$status"
-}
-trap cleanup EXIT
-
-psql_root() {
- PGPASSWORD="$PGPASSWORD" psql -q -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d postgres "$@"
-}
-
-psql_test() {
- PGPASSWORD="$PGPASSWORD" psql -q -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" "$@"
-}
-
-wait_for_http() {
- for _ in $(seq 1 60); do
- if curl -fsS "$BASE_URL/" >/dev/null 2>&1; then
- return 0
- fi
- sleep 1
- done
-
- echo "backend did not become ready; last log lines:" >&2
- tail -120 "$LOG_FILE" >&2 || true
- return 1
-}
-
-json_field() {
- jq -r "$1"
-}
-
-register_user() {
- local username="$1"
- local password="$2"
- curl -fsS -X POST "$BASE_URL/auth/register" \
- -F "name=$username" \
- -F "username=$username" \
- -F "password=$password" \
- -F "email=$username@example.test" >/dev/null
-}
-
-login_user() {
- local username="$1"
- local password="$2"
- curl -fsS -X POST "$BASE_URL/auth/login" \
- -F "username=$username" \
- -F "password=$password" | json_field '.token'
-}
-
-seed_challenge() {
- local name="$1"
- local flag="$2"
- local max_attempts="$3"
- local dynamic="$4"
- local author_id
- author_id="$(psql_test -Atc "SELECT id FROM users ORDER BY id LIMIT 1")"
-
- psql_test -Atc "
- INSERT INTO challenges (
- created_at,
- updated_at,
- name,
- dynamic_flag,
- flag,
- type,
- difficulty,
- max_attempt_limit,
- format,
- container_id,
- image_id,
- status,
- deployment_type,
- author_id,
- health_check,
- points,
- max_points,
- min_points,
- server_deployed
- )
- VALUES (
- now(),
- now(),
- '$name',
- $dynamic,
- '$flag',
- 'web',
- 'easy',
- $max_attempts,
- 'web',
- 'container-$name',
- 'image-$name',
- 'Deployed',
- 'standard_docker',
- $author_id,
- 0,
- 500,
- 500,
- 100,
- 'localhost'
- )
- RETURNING id"
-}
-
-submit_concurrently() {
- local token="$1"
- local challenge_id="$2"
- local flag="$3"
- local requests="$4"
-
- python3 - "$BASE_URL" "$token" "$challenge_id" "$flag" "$requests" <<'PY'
-import concurrent.futures
-import json
-import sys
-import urllib.error
-import urllib.parse
-import urllib.request
-
-base_url, token, challenge_id, flag, requests = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4], int(sys.argv[5])
-
-def submit(_):
- data = urllib.parse.urlencode({"chall_id": challenge_id, "flag": flag}).encode()
- request = urllib.request.Request(
- base_url + "/api/submit/challenge",
- data=data,
- headers={"Authorization": "Bearer " + token},
- method="POST",
- )
- try:
- with urllib.request.urlopen(request, timeout=10) as response:
- body = response.read().decode()
- return response.status, json.loads(body)
- except urllib.error.HTTPError as error:
- body = error.read().decode()
- try:
- parsed = json.loads(body)
- except json.JSONDecodeError:
- parsed = {"raw": body}
- return error.code, parsed
-
-with concurrent.futures.ThreadPoolExecutor(max_workers=min(32, requests)) as executor:
- results = list(executor.map(submit, range(requests)))
-
-print(json.dumps(results))
-PY
-}
-
-assert_one_success() {
- local results_json="$1"
- local successes
- successes="$(jq '[.[] | select(.[1].success == true)] | length' <<<"$results_json")"
- if [[ "$successes" != "1" ]]; then
- echo "expected exactly one successful submit response, got $successes" >&2
- jq . <<<"$results_json" >&2
- return 1
- fi
-}
-
-assert_zero_successes() {
- local results_json="$1"
- local successes
- successes="$(jq '[.[] | select(.[1].success == true)] | length' <<<"$results_json")"
- if [[ "$successes" != "0" ]]; then
- echo "expected zero successful submit responses, got $successes" >&2
- jq . <<<"$results_json" >&2
- return 1
- fi
-}
-
-rm -rf "$TEST_HOME"
-mkdir -p "$TEST_HOME/.beast/scripts" "$TEST_HOME/.beast/cache" "$TEST_HOME/.beast/remotes" "$TEST_HOME/.beast/uploads" "$TEST_HOME/.beast/secrets" "$TEST_HOME/.beast/staging" "$TEST_HOME/.beast/assets/logo"
-
-psql_root -v ON_ERROR_STOP=1 -c "DROP DATABASE IF EXISTS $PGDATABASE WITH (FORCE)" >/dev/null
-psql_root -v ON_ERROR_STOP=1 -c "CREATE DATABASE $PGDATABASE" >/dev/null
-cat >"$TEST_HOME/.beast/config.toml" <"$LOG_FILE" 2>&1
-) &
-SERVER_PID=$!
-
-wait_for_http
-
-register_user "apiwinner" "pw"
-TOKEN_WINNER="$(login_user "apiwinner" "pw")"
-CHALLENGE_CORRECT_ID="$(seed_challenge "api-race-correct" "flag{api-correct}" -1 false)"
-CORRECT_RESULTS="$(submit_concurrently "$TOKEN_WINNER" "$CHALLENGE_CORRECT_ID" "flag{api-correct}" 64)"
-assert_one_success "$CORRECT_RESULTS"
-
-WINNER_SCORE="$(psql_test -Atc "SELECT score FROM users WHERE username = 'apiwinner'")"
-if [[ "$WINNER_SCORE" != "500" ]]; then
- echo "expected apiwinner score 500, got $WINNER_SCORE" >&2
- exit 1
-fi
-SOLVED_ROWS="$(psql_test -Atc "SELECT COUNT(*) FROM user_challenges WHERE user_id = (SELECT id FROM users WHERE username = 'apiwinner') AND challenge_id = $CHALLENGE_CORRECT_ID AND solved = true")"
-if [[ "$SOLVED_ROWS" != "1" ]]; then
- echo "expected one solved user_challenges row, got $SOLVED_ROWS" >&2
- exit 1
-fi
-
-register_user "apiwrong" "pw"
-TOKEN_WRONG="$(login_user "apiwrong" "pw")"
-CHALLENGE_WRONG_ID="$(seed_challenge "api-race-wrong" "flag{api-wrong}" 3 false)"
-WRONG_RESULTS="$(submit_concurrently "$TOKEN_WRONG" "$CHALLENGE_WRONG_ID" "not-the-flag" 64)"
-assert_zero_successes "$WRONG_RESULTS"
-
-WRONG_TRIES="$(psql_test -Atc "SELECT tries FROM user_challenges WHERE user_id = (SELECT id FROM users WHERE username = 'apiwrong') AND challenge_id = $CHALLENGE_WRONG_ID")"
-if [[ "$WRONG_TRIES" != "3" ]]; then
- echo "expected apiwrong tries 3, got $WRONG_TRIES" >&2
- exit 1
-fi
-WRONG_SCORE="$(psql_test -Atc "SELECT score FROM users WHERE username = 'apiwrong'")"
-if [[ "$WRONG_SCORE" != "0" ]]; then
- echo "expected apiwrong score 0, got $WRONG_SCORE" >&2
- exit 1
-fi
-
-register_user "apidynone" "pw"
-register_user "apidyntwo" "pw"
-TOKEN_DYN_ONE="$(login_user "apidynone" "pw")"
-TOKEN_DYN_TWO="$(login_user "apidyntwo" "pw")"
-CHALLENGE_DYNAMIC_ID="$(seed_challenge "api-race-dynamic" "unused-static-flag" -1 true)"
-psql_test -v ON_ERROR_STOP=1 -c "INSERT INTO dynamic_flags (created_at, updated_at, name, flag) VALUES (now(), now(), 'api-race-dynamic', 'flag{dynamic-shared}')" >/dev/null
-
-DYNAMIC_RESULTS="$(
- python3 - "$BASE_URL" "$TOKEN_DYN_ONE" "$TOKEN_DYN_TWO" "$CHALLENGE_DYNAMIC_ID" <<'PY'
-import concurrent.futures
-import json
-import sys
-import urllib.parse
-import urllib.request
-
-base_url, token_one, token_two, challenge_id = sys.argv[1:5]
-
-def submit(token):
- data = urllib.parse.urlencode({"chall_id": challenge_id, "flag": "flag{dynamic-shared}"}).encode()
- request = urllib.request.Request(
- base_url + "/api/submit/challenge",
- data=data,
- headers={"Authorization": "Bearer " + token},
- method="POST",
- )
- with urllib.request.urlopen(request, timeout=10) as response:
- return response.status, json.loads(response.read().decode())
-
-with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
- results = list(executor.map(submit, [token_one, token_two]))
-print(json.dumps(results))
-PY
-)"
-assert_one_success "$DYNAMIC_RESULTS"
+set -euo pipefail
-DYNAMIC_CLAIMS="$(psql_test -Atc "SELECT COUNT(*) FROM dynamic_flag_claims WHERE challenge_id = $CHALLENGE_DYNAMIC_ID AND flag = 'flag{dynamic-shared}'")"
-if [[ "$DYNAMIC_CLAIMS" != "1" ]]; then
- echo "expected one dynamic flag claim, got $DYNAMIC_CLAIMS" >&2
- exit 1
+if [[ -z "${BEAST_TEST_PG_DSN:-}" ]]; then
+ echo "set BEAST_TEST_PG_DSN to an isolated PostgreSQL database" >&2
+ echo "the test creates and drops temporary schemas" >&2
+ exit 2
fi
-LEADERBOARD="$(curl -fsS -H "Authorization: Bearer $TOKEN_WINNER" "$BASE_URL/api/info/leaderboard?page=1")"
-if ! jq -e '.[] | select(.username == "apiwinner" and .score == 500)' <<<"$LEADERBOARD" >/dev/null; then
- echo "leaderboard did not include apiwinner score 500" >&2
- jq . <<<"$LEADERBOARD" >&2
- exit 1
-fi
+repo_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
+cd "${repo_dir}"
-echo "backend submit race verification passed"
+go test -race ./core/database \
+ -run 'TestConcurrentCorrectSubmissionsAwardOnce|TestConcurrentWrongSubmissionsRespectMaxAttempts|TestDynamicFlagClaimFirstClaimWins|TestDynamicScoreDirtyCoalescesConcurrentMarks' \
+ -count=1
diff --git a/scripts/test/test_examples.sh b/scripts/test/test_examples.sh
index 092da832..6fee64e0 100755
--- a/scripts/test/test_examples.sh
+++ b/scripts/test/test_examples.sh
@@ -1,176 +1,46 @@
-#!/bin/bash
+#!/usr/bin/env bash
-PWD=$(pwd)
+set -euo pipefail
-ERROR="\e[31;1mERROR\e[0m"
-SUCCESS="\e[32;1mSUCCESS\e[0m"
-INFO="\e[34;1mINFO\e[0m"
-
-BEAST="${GOPATH}/bin/beast"
-
-checkPortAvailable() {
- local challenge=$1
- local port=$2
- echo -e "$INFO : $challenge : Check port availability"
- (echo -e 1 > /dev/tcp/127.0.0.1/$port) 2> /dev/null
- if [[ $? -eq 0 ]]; then
- echo -e "$ERROR : $challenge : port $port is not free, cannot test challenge"
- exit 1
- fi
-}
-
-deployChallenge() {
- local challenge=$1
- local challdir=$2
- echo -e "$INFO : $challenge : Start deploy"
- $BEAST -v challenge deploy --local-directory $challdir
- if [[ $? -ne 0 ]]; then
- echo -e "$ERROR: $challenge : There was an error in deployment of challenge"
- exit 1
- fi
-}
-
-checkPortReachable() {
- local port=$1
- (echo -e 1 > /dev/tcp/127.0.0.1/$port) 2> /dev/null
-}
-
-purge() {
- local challenge=$1
- echo -e "$INFO : $challenge : Purge challenge"
- $BEAST -v challenge purge $challenge -d
- if [[ $? -ne 0 ]]; then
- echo -e "$ERROR : $challenge : Error while purging"
- exit 1
- fi
-}
-
-doHTTPProbe() {
- local url=$1
- curl --write-out %{http_code} --silent --output /dev/null $url
-}
-
-# Test challenge simple
-CHALLENGE="simple"
-PORT=10001
-## Check if port is taken
-checkPortAvailable $CHALLENGE $PORT
-## Deploy challenge
-deployChallenge $CHALLENGE $PWD/_examples/$CHALLENGE
-## Test deployment
-echo -e "$INFO : $CHALLENGE : Test deployment"
-checkPortReachable $PORT
-if [[ $? -eq 0 ]]; then
- echo -e "$SUCCESS: $CHALLENGE : Deployed successfully"
-else
- echo -e "$ERROR: $CHALLENGE : There was an error in deployment of challenge"
- exit 1
+if [[ "${BEAST_RUN_INTEGRATION:-}" != "1" ]]; then
+ echo "integration tests are destructive; run 'make integration-test' explicitly" >&2
+ exit 2
fi
-#Test challenge static-chall
-CHALLENGE="static-chall"
-#Test beast-static container
-echo -e "$INFO : $CHALLENGE : Test beast-static container"
-docker ps | grep -q 'beast-static'
-if [[ $? -ne 0 ]]; then
- echo -e "$ERROR: $CHALLENGE : beast-static container is not running"
- exit 1
-fi
-## Deploy challenge
-deployChallenge $CHALLENGE $PWD/_examples/$CHALLENGE
-## Test deployment
-echo -e "$INFO : $CHALLENGE : Test deployment"
-response_code=$(doHTTPProbe "http://localhost/static/$CHALLENGE/index.html")
-if [[ $response_code -eq 200 ]]; then
- echo -e "$SUCCESS : $CHALLENGE : Deployed successfully"
-else
- echo -e "$ERROR : $CHALLENGE : There was an error in deployment of challenge"
- exit 1
-fi
+repo_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
+beast_bin=${BEAST_BIN:-"$(go env GOPATH)/bin/beast"}
+challenge_name="simple"
+challenge_dir="${repo_dir}/_examples/${challenge_name}"
+challenge_port=10005
+deployed=false
-# Test challenge web-php
-CHALLENGE="web-php"
-PORT=10002
-## Check if port is taken
-checkPortAvailable $CHALLENGE $PORT
-## Deploy challenge
-deployChallenge $CHALLENGE $PWD/_examples/$CHALLENGE
-## Test deployment
-echo -e "$INFO : $CHALLENGE : Test deployment"
-response_code=$(doHTTPProbe "http://localhost:$PORT/index.php")
-if [[ $response_code -eq 200 ]]; then
- echo -e "$SUCCESS : $CHALLENGE : Deployed successfully"
-else
- echo -e "$ERROR : $CHALLENGE : There was an error in deployment of challenge"
- exit 1
+if [[ ! -x "${beast_bin}" ]]; then
+ echo "Beast executable not found: ${beast_bin}" >&2
+ exit 1
fi
-# Test challenge web-php-mysql
-CHALLENGE="web-php-mysql"
-PORT=10004
-## Check if port is taken
-checkPortAvailable $CHALLENGE $PORT
-## Test beast-mysql container
-echo -e "$INFO : $CHALLENGE : Test beast-mysql container"
-docker ps | grep -q 'beast-mysql'
-if [[ $? -ne 0 ]]; then
- echo -e "$ERROR: $CHALLENGE : beast-mysql container is not running"
- exit 1
-fi
-## Deploy challenge
-deployChallenge $CHALLENGE $PWD/_examples/$CHALLENGE
-## Test deployment
-echo -e "$INFO : $CHALLENGE : Test deployment"
-response_code=$(doHTTPProbe "http://localhost:$PORT/index.php")
-if [[ $response_code -eq 200 ]]; then
- echo -e "$SUCCESS : $CHALLENGE : Deployed successfully"
-else
- echo -e "$ERROR : $CHALLENGE : There was an error in deployment of challenge"
- exit 1
-fi
+cleanup() {
+ if [[ "${deployed}" == true ]]; then
+ "${beast_bin}" challenge purge "${challenge_name}" --delete-entry || true
+ fi
+}
+trap cleanup EXIT INT TERM
-# Test challenge xinetd-service
-CHALLENGE="xinetd-service"
-PORT=10003
-## Check if port is taken
-checkPortAvailable $CHALLENGE $PORT
-## Deploy challenge
-deployChallenge $CHALLENGE $PWD/_examples/$CHALLENGE
-## Test deployment
-echo -e "$INFO : $CHALLENGE : Test deployment"
-checkPortReachable $PORT
-if [[ $? -eq 0 ]]; then
- echo -e "$SUCCESS : $CHALLENGE : Deployed successfully"
-else
- echo -e "$ERROR : $CHALLENGE : There was an error in deployment of challenge"
- exit 1
+if timeout 1 bash -c "/dev/null; then
+ echo "port ${challenge_port} is already in use" >&2
+ exit 1
fi
-# Test challenge xinetd-service
-CHALLENGE="docker-type"
-PORT=10005
-## Check if port is taken
-checkPortAvailable $CHALLENGE $PORT
-## Deploy challenge
-deployChallenge $CHALLENGE $PWD/_examples/$CHALLENGE
-## Test deployment
-echo -e "$INFO : $CHALLENGE : Test deployment"
-checkPortReachable $PORT
-if [[ $? -eq 0 ]]; then
- echo -e "$SUCCESS : $CHALLENGE : Deployed successfully"
-else
- echo -e "$ERROR : $CHALLENGE : There was an error in deployment of challenge"
- exit 1
-fi
+"${beast_bin}" challenge deploy --local-directory "${challenge_dir}"
+deployed=true
+
+for _ in {1..30}; do
+ if timeout 1 bash -c "/dev/null; then
+ echo "challenge ${challenge_name} is reachable on port ${challenge_port}"
+ exit 0
+ fi
+ sleep 1
+done
-## Purge all challenges
-# simple
-purge simple
-# static-chall
-purge static-chall
-# web-php
-purge web-php
-# web-php-mysql
-purge web-php-mysql
-# xinetd-service
-purge xinetd-service
+echo "challenge ${challenge_name} did not become reachable" >&2
+exit 1
diff --git a/scripts/tools/swagger-docs.py b/scripts/tools/swagger-docs.py
index 9ca9d33b..e2af604c 100755
--- a/scripts/tools/swagger-docs.py
+++ b/scripts/tools/swagger-docs.py
@@ -1,4 +1,4 @@
-#!/usr/bin/python
+#!/usr/bin/env python3
TEMPLATE = """
@@ -86,7 +86,7 @@