diff --git a/_examples/example.config.toml b/_examples/example.config.toml
index f54a1876..5985eb72 100644
--- a/_examples/example.config.toml
+++ b/_examples/example.config.toml
@@ -30,6 +30,9 @@ default_cpu_shares = 1024
default_memory_limit = 1024
default_pids_limit = 100
+# Port range for localhost deployments (format: START:END)
+local_host_port_range = "30000:40000"
+
# List of ip addresses of all the servers where challenge could be deployed for
# balanced load accross servers.
[available_servers]
@@ -44,6 +47,9 @@ username = "user1"
# Path to private SSH key for interacting with the server.
ssh_key_path = "/path/to/your/private/key1"
+# Port range for this server (format: START:END)
+port_range = "30000:40000"
+
# Status of remote server to be used
# If it is set to false then that remote server will not be used
active = false
@@ -53,10 +59,13 @@ active = false
host = "localhost"
# Username to be used for ssh connection (Leave empty for localhost)
-username = "user1"
+username = ""
# Path to private SSH key for interacting with the server. (Leave empty for localhost)
-ssh_key_path = "/path/to/your/private/key1"
+ssh_key_path = ""
+
+# Port range for this server (format: START:END) - uses local_host_port_range if empty
+port_range = ""
# Status of remote server to be used
active = true
@@ -113,6 +122,19 @@ host = "localhost"
port = "5432"
sslmode = "prefer"
+[redis_config]
+host = "localhost"
+port = "6379"
+password = ""
+user = ""
+
+[instance_config]
+# Port Range for localhost. per-server this is configured via `port-range`
+local_host_port_range = '10000:11000'
+default_expiration = 300
+max_extension = 600
+max_instances_per_user = 3
+
# 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/Dockerfile b/_examples/instanced-compose/Dockerfile
new file mode 100644
index 00000000..360a032e
--- /dev/null
+++ b/_examples/instanced-compose/Dockerfile
@@ -0,0 +1,12 @@
+FROM php:7.4-apache
+
+# Install MySQL extension
+RUN docker-php-ext-install mysqli pdo pdo_mysql
+
+# Copy challenge files
+COPY challenge/ /var/www/html/
+
+# Set permissions
+RUN chown -R www-data:www-data /var/www/html
+
+EXPOSE 80
diff --git a/_examples/instanced-compose/README.md b/_examples/instanced-compose/README.md
new file mode 100644
index 00000000..d6240e8d
--- /dev/null
+++ b/_examples/instanced-compose/README.md
@@ -0,0 +1,89 @@
+# Instanced Docker Compose Challenge 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.
+
+## 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
+
+```bash
+# Build and run locally (for testing)
+cd _examples/instanced-compose
+docker-compose up -d
+
+# Access at http://localhost:8080
+```
+
+## Usage via Beast API
+
+```bash
+# Spawn your instance
+curl -X POST -H "Authorization: Bearer $TOKEN" \
+ http://localhost:8080/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
+# }
+
+# Access your instance
+open http://localhost:31234
+```
diff --git a/_examples/instanced-compose/beast.toml b/_examples/instanced-compose/beast.toml
new file mode 100644
index 00000000..a06fb772
--- /dev/null
+++ b/_examples/instanced-compose/beast.toml
@@ -0,0 +1,33 @@
+[author]
+name = "beast-admin"
+email = "admin@beast.local"
+ssh_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ"
+
+[challenge.metadata]
+name = "instanced-compose"
+flag = "FLAG{c0mp0s3_1nst4nc3s_r0ck!}"
+type = "web"
+description = "A web challenge with database backend. Each user gets their own isolated environment!"
+points = 200
+difficulty = "medium"
+tags = ["web", "sql", "instanced"]
+maxAttemptLimit = 100
+instanced = true
+instance_expiration = 12
+
+[[challenge.metadata.hints]]
+text = "Check for SQL injection vulnerabilities"
+points = 30
+
+[[challenge.metadata.hints]]
+text = "The admin password might be in the database..."
+points = 50
+
+[challenge.env]
+docker_compose = "docker-compose.yml"
+default_port = 8080
+
+[resource]
+cpu_shares = 1024
+memory_limit = 536870912
+pids_limit = 100
diff --git a/_examples/instanced-compose/challenge/index.php b/_examples/instanced-compose/challenge/index.php
new file mode 100644
index 00000000..42a0e5a5
--- /dev/null
+++ b/_examples/instanced-compose/challenge/index.php
@@ -0,0 +1,158 @@
+
+
+
+ Secret Vault - Login
+
+
+
+
+
Secret Vault
+
+ connect_error) {
+ $error = "Connection failed. Please try again.";
+ } else {
+ $username = $_POST['username'];
+ $password = $_POST['password'];
+
+ // VULNERABLE: SQL Injection!
+ $query = "SELECT * FROM users WHERE username='$username' AND password='$password'";
+ $result = $conn->query($query);
+
+ if ($result && $result->num_rows > 0) {
+ $row = $result->fetch_assoc();
+
+ if ($row['role'] === 'admin') {
+ // Admin login - show secrets
+ $secrets_query = "SELECT * FROM secrets";
+ $secrets_result = $conn->query($secrets_query);
+
+ $success = "Welcome Admin! Here are your secrets:
";
+ while ($secret = $secrets_result->fetch_assoc()) {
+ $success .= "
" . htmlspecialchars($secret['secret_name']) . ": " .
+ htmlspecialchars($secret['secret_value']) . "
";
+ }
+ } else {
+ $success = "Welcome, " . htmlspecialchars($row['username']) . "! You're logged in as a regular user.";
+ }
+ } else {
+ $error = "Invalid username or password!";
+ }
+
+ $conn->close();
+ }
+ }
+ ?>
+
+
+
+
+
+
+
+
+
+
+
+
Hint: Try logging in as admin to see the secrets!
+
+
+
diff --git a/_examples/instanced-compose/docker-compose.yml b/_examples/instanced-compose/docker-compose.yml
new file mode 100644
index 00000000..2f42ca8f
--- /dev/null
+++ b/_examples/instanced-compose/docker-compose.yml
@@ -0,0 +1,29 @@
+version: '3.8'
+
+services:
+ web:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ ports:
+ # Use INSTANCE_PORT env var if available, otherwise default to 8080
+ - "${INSTANCE_PORT:-8080}:80"
+ environment:
+ - DB_HOST=db
+ - DB_USER=challenge
+ - DB_PASS=challengepass
+ - DB_NAME=ctf
+ depends_on:
+ - db
+ restart: unless-stopped
+
+ db:
+ image: mysql:5.7
+ environment:
+ - MYSQL_ROOT_PASSWORD=rootpass
+ - MYSQL_DATABASE=ctf
+ - MYSQL_USER=challenge
+ - MYSQL_PASSWORD=challengepass
+ volumes:
+ - ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro
+ restart: unless-stopped
diff --git a/_examples/instanced-compose/init.sql b/_examples/instanced-compose/init.sql
new file mode 100644
index 00000000..f7acd93a
--- /dev/null
+++ b/_examples/instanced-compose/init.sql
@@ -0,0 +1,26 @@
+-- Initialize the CTF database
+
+USE ctf;
+
+CREATE TABLE users (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ username VARCHAR(50) NOT NULL,
+ password VARCHAR(255) NOT NULL,
+ role VARCHAR(20) DEFAULT 'user'
+);
+
+CREATE TABLE secrets (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ secret_name VARCHAR(100) NOT NULL,
+ secret_value TEXT NOT NULL
+);
+
+-- Insert some users
+INSERT INTO users (username, password, role) VALUES
+ ('guest', 'guest123', 'user'),
+ ('admin', 'sup3rs3cr3t_4dm1n_p4ss!', 'admin');
+
+-- Insert the flag as a secret
+INSERT INTO secrets (secret_name, secret_value) VALUES
+ ('flag', 'FLAG{c0mp0s3_1nst4nc3s_r0ck!}'),
+ ('admin_note', 'Remember to change the admin password!');
diff --git a/_examples/instanced-service/README.md b/_examples/instanced-service/README.md
new file mode 100644
index 00000000..7710cbcf
--- /dev/null
+++ b/_examples/instanced-service/README.md
@@ -0,0 +1,119 @@
+# Instanced Service Challenge Example
+
+This is an example of an **instanced challenge** - a challenge where each user gets their own dedicated container instance.
+
+## 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
+ ```
+
+## Response Example
+
+When spawning an instance, you'll receive:
+
+```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
+}
+```
+
+Connect to your instance:
+```bash
+nc localhost 31234
+```
+
+## Challenge Details
+
+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
diff --git a/_examples/instanced-service/beast.toml b/_examples/instanced-service/beast.toml
new file mode 100644
index 00000000..d29fa869
--- /dev/null
+++ b/_examples/instanced-service/beast.toml
@@ -0,0 +1,31 @@
+[author]
+name = "beast-admin"
+email = "admin@beast.local"
+ssh_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ"
+
+[challenge.metadata]
+name = "instanced-service"
+flag = "FLAG{1nst4nc3d_ch4ll3ng3_w0rks!}"
+type = "service"
+description = "A simple buffer overflow challenge. Each user gets their own instance!"
+points = 150
+difficulty = "easy"
+tags = ["pwn", "beginner", "instanced"]
+maxAttemptLimit = 50
+instanced = true
+instance_expiration = 10
+
+[[challenge.metadata.hints]]
+text = "Have you tried overflowing the buffer?"
+points = 25
+
+[[challenge.metadata.hints]]
+text = "The sample() function looks interesting..."
+points = 50
+
+[challenge.env]
+default_port = 9999
+apt_deps = ["gcc", "xinetd"]
+setup_scripts = ["setup.sh"]
+service_path = "pwn"
+base_image = "ubuntu:18.04"
diff --git a/_examples/instanced-service/pwn_me.c b/_examples/instanced-service/pwn_me.c
new file mode 100644
index 00000000..2ac0ae7e
--- /dev/null
+++ b/_examples/instanced-service/pwn_me.c
@@ -0,0 +1,50 @@
+#include
+#include
+#include
+#include
+
+// Compile with: gcc -o pwn pwn_me.c -fno-stack-protector -no-pie
+
+void win() {
+ FILE *fp;
+ char flag[100];
+
+ fp = fopen("/challenge/flag.txt", "r");
+ if (fp == NULL) {
+ printf("Error: Could not open flag file!\n");
+ return;
+ }
+
+ if (fgets(flag, sizeof(flag), fp) != NULL) {
+ printf("Congratulations! Here's your flag: %s\n", flag);
+ }
+
+ fclose(fp);
+}
+
+void vulnerable() {
+ char buffer[64];
+
+ printf("Welcome to the Instanced PWN Challenge!\n");
+ printf("Each user gets their own container instance.\n");
+ printf("Can you overflow the buffer and call win()?\n\n");
+ printf("Enter your payload: ");
+ fflush(stdout);
+
+ // Vulnerable: no bounds checking!
+ gets(buffer);
+
+ printf("You entered: %s\n", buffer);
+ printf("Better luck next time!\n");
+}
+
+int main() {
+ // Disable buffering for proper network I/O
+ setvbuf(stdin, NULL, _IONBF, 0);
+ setvbuf(stdout, NULL, _IONBF, 0);
+ setvbuf(stderr, NULL, _IONBF, 0);
+
+ vulnerable();
+
+ return 0;
+}
diff --git a/_examples/instanced-service/setup.sh b/_examples/instanced-service/setup.sh
new file mode 100644
index 00000000..c8a6f9dc
--- /dev/null
+++ b/_examples/instanced-service/setup.sh
@@ -0,0 +1,16 @@
+#!/bin/bash
+set -e
+
+echo "[*] Setting up instanced-service challenge..."
+
+# Compile the vulnerable binary
+gcc -o pwn pwn_me.c -fno-stack-protector -no-pie -z execstack
+
+# Make it executable
+chmod +x pwn
+
+# Create flag file
+echo "FLAG{1nst4nc3d_ch4ll3ng3_w0rks!}" > flag.txt
+chmod 444 flag.txt
+
+echo "[*] Setup complete!"
diff --git a/api/submit.go b/api/check.go
similarity index 50%
rename from api/submit.go
rename to api/check.go
index a37f3fde..c3a1b27d 100644
--- a/api/submit.go
+++ b/api/check.go
@@ -1,36 +1,39 @@
package api
import (
- "math"
+ "fmt"
+ "github.com/sdslabs/beastv4/core/cache"
+ "github.com/sdslabs/beastv4/core/config"
+ "github.com/sdslabs/beastv4/core/manager"
+ "github.com/sdslabs/beastv4/pkg/cr"
+ "github.com/sdslabs/beastv4/pkg/remoteManager"
"net/http"
"strconv"
+ "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/notify"
- log "github.com/sirupsen/logrus"
)
-// Verifies and creates an entry in the database for successful submission of flag for a challenge.
-// @Summary Verifies and creates an entry in the database for successful submission of flag for a challenge.
-// @Description Returns success or error response based on the flag submitted. Also, the flag will not be submitted if it was previously submitted
+// Verifies and creates an entry in the database for successful submission for a challenge.
+// @Summary Verifies and creates an entry in the database for successful submission of a challenge.
+// @Description Returns success or error response based on the status of completion.
// @Tags Submit
// @Accept json
// @Produce json
// @Param chall_id formData string true "Name of challenge"
-// @Param flag formData string true "Flag for the challenge"
+// @Param instance_id formData string true "Instance ID for challenge"
// @Success 200 {object} api.ChallengeStatusResp
// @Failure 400 {object} api.HTTPPlainResp
// @Failure 401 {object} api.HTTPPlainResp
// @Failure 500 {object} api.HTTPPlainResp
-// @Router /api/submit/challenge [post]
-func submitFlagHandler(c *gin.Context) {
+// @Router /api/check/challenge [post]
+func checkFlagHandler(c *gin.Context) {
challId := c.PostForm("chall_id")
- flag := c.PostForm("flag")
+ instanceId := c.PostForm("instance_id")
err, state := coreUtils.CheckTime()
if err != nil {
@@ -67,9 +70,9 @@ func submitFlagHandler(c *gin.Context) {
return
}
- if flag == "" {
+ if instanceId == "" {
c.JSON(http.StatusBadRequest, HTTPErrorResp{
- Error: "Flag for the challenge is a required parameter to process request.",
+ Error: "Instance of id is required",
})
return
}
@@ -107,7 +110,7 @@ func submitFlagHandler(c *gin.Context) {
challenge := chall[0]
if challenge.Status != core.DEPLOY_STATUS["deployed"] {
- c.JSON(http.StatusOK, FlagSubmitResp{
+ c.JSON(http.StatusOK, ChallengeSubmitResponse{
Message: "Challenge is unavailable",
Success: false,
})
@@ -125,7 +128,7 @@ func submitFlagHandler(c *gin.Context) {
}
if !preReqsStatus {
- c.JSON(http.StatusOK, FlagSubmitResp{
+ c.JSON(http.StatusOK, ChallengeSubmitResponse{
Message: "You have not solved the prerequisites of this challenge.",
Success: false,
})
@@ -142,7 +145,7 @@ func submitFlagHandler(c *gin.Context) {
}
if previousTries >= challenge.MaxAttemptLimit {
- c.JSON(http.StatusOK, FlagSubmitResp{
+ c.JSON(http.StatusOK, ChallengeSubmitResponse{
Message: "You have reached the maximum number of tries for this challenge.",
Success: false,
})
@@ -168,108 +171,76 @@ func submitFlagHandler(c *gin.Context) {
}
if solved {
- c.JSON(http.StatusOK, FlagSubmitResp{
+ c.JSON(http.StatusOK, ChallengeSubmitResponse{
Message: "Challenge has already been solved.",
Success: false,
})
return
}
- // If the challenge is dynamic, then the flag is not stored in the database
- if challenge.DynamicFlag {
- whereMap := map[string]interface{}{
- "Name": challenge.Name,
- "Flag": flag,
- }
- validFlags, err := database.QueryDynamicFlagEntries(whereMap)
- if err != nil {
- c.JSON(http.StatusInternalServerError, HTTPErrorResp{
- Error: "DATABASE ERROR while processing the request.",
- })
- return
- }
+ instance, err := cache.GetInstance(instanceId)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, HTTPErrorResp{
+ Error: "CACHE ERROR while processing the request.",
+ })
+ return
+ }
- // flag not present in validFlags table
- if len(validFlags) == 0 {
- c.JSON(http.StatusOK, FlagSubmitResp{
- Message: "Your flag is incorrect",
- Success: false,
- })
- return
- }
+ if instance.Username != username {
+ c.JSON(http.StatusUnauthorized, HTTPErrorResp{
+ Error: "Unauthorized user",
+ })
+ return
+ }
- wheremap := map[string]interface{}{
- "challenge_id": challenge.ID,
- "flag": flag,
- }
- submissions, err := database.QuerySubmissions(wheremap)
- if err != nil {
- c.JSON(http.StatusInternalServerError, HTTPErrorResp{
- Error: "DATABASE ERROR while processing the request.",
- })
- return
- }
- if len(submissions) > 0 {
- if user.ID != submissions[0].UserID {
- // notify the admin about cheating
- subuser, _ := database.QueryUserById(submissions[0].UserID)
- msg := "User " + subuser.Username + " has submitted the flag " + flag + " for challenge " + challenge.Name + " which has already been solved by another user " + user.Username
- go notify.SendNotification(notify.Warning, msg)
- go coreUtils.LogCheating(msg)
- } else {
- c.JSON(http.StatusOK, FlagSubmitResp{
- Message: "You have already solved this challenge",
- Success: false,
- })
- return
- }
- }
- } else {
- if challenge.Flag != flag {
- UserChallengesEntry := database.UserChallenges{
- CreatedAt: time.Now(),
- UserID: user.ID,
- ChallengeID: challenge.ID,
- Solved: false,
- Flag: flag,
- }
- err = database.SaveFlagSubmission(&UserChallengesEntry)
- if err != nil {
- c.JSON(http.StatusInternalServerError, HTTPErrorResp{
- Error: "DATABASE ERROR while processing the request.",
- })
- return
- }
- c.JSON(http.StatusOK, FlagSubmitResp{
- Message: "Your flag is incorrect",
- Success: false,
- })
- return
- }
+ localDeploy := instance.ServerDeployed == core.LOCALHOST || instance.ServerDeployed == ""
+
+ exists, err := checkScriptExistence(localDeploy, instance)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, HTTPErrorResp{
+ Error: fmt.Sprintf("CONTAINER RUNTIME ERROR while verifying the existance of check script: %s", err.Error()),
+ })
+ return
}
- challengePoints := challenge.Points
- log.Debugf("Dynamic scoring is set to %t", config.Cfg.CompetitionInfo.DynamicScore)
- if config.Cfg.CompetitionInfo.DynamicScore {
- submissions, err := database.QuerySubmissions(map[string]interface{}{
- "challenge_id": parsedChallId,
+
+ if !exists {
+ c.JSON(http.StatusInternalServerError, HTTPErrorResp{
+ Error: fmt.Sprintf("VALIDATION ERROR: check script not found at %s.", core.SAD_CHECK_SCRIPT_LOCATION),
})
- if err != nil {
- log.Error(err)
- }
- solvers := len(submissions)
- newPoints := dynamicScore(challenge.MaxPoints, challenge.MinPoints, uint(solvers))
- if newPoints != challengePoints {
- database.UpdateChallenge(&challenge, map[string]interface{}{
- "Points": newPoints,
- })
- log.Debugf("By dynamic scoring the points of challenge %s are changed to %d from %d", challenge.Name, newPoints, challengePoints)
- err = updatePointsOfSolvers(submissions, newPoints, challengePoints)
- if err != nil {
- log.Error(err)
- }
- challengePoints = newPoints
- }
+ return
}
+
+ verified, err := validateCheckScriptHash(localDeploy, instance)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, HTTPErrorResp{
+ Error: fmt.Sprintf("CONTAINER RUNTIME ERROR while processing the request: %s", err.Error()),
+ })
+ return
+ }
+ if !verified {
+ c.JSON(http.StatusInternalServerError, HTTPErrorResp{
+ Error: "VALIDATION ERROR: hash of check.sh does not match, file tampered with.",
+ })
+ return
+ }
+
+ result, err := executeCheckScript(localDeploy, instance)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, HTTPErrorResp{
+ Error: fmt.Sprintf("CONTAINER RUNTIME ERROR while executing check script: %s", err.Error()),
+ })
+ return
+ }
+
+ if result.ExitCode != 0 {
+ c.JSON(http.StatusOK, ChallengeSubmitResponse{
+ Message: fmt.Sprintf("Challenge check failed with EXIT CODE: %v\nLOGS: %s", result.ExitCode, result.Output),
+ Success: false,
+ })
+ return
+ }
+
+ challengePoints := challenge.Points
newScore := user.Score + challengePoints
if newScore <= 0 {
newScore = 0
@@ -293,10 +264,9 @@ func submitFlagHandler(c *gin.Context) {
UserID: user.ID,
ChallengeID: challenge.ID,
Solved: true,
- Flag: flag,
}
- err = database.SaveFlagSubmission(&UserChallengesEntry)
+ err = database.SaveChallengeSubmission(&UserChallengesEntry)
if err != nil {
c.JSON(http.StatusInternalServerError, HTTPErrorResp{
Error: "DATABASE ERROR while processing the request.",
@@ -304,8 +274,8 @@ func submitFlagHandler(c *gin.Context) {
return
}
- c.JSON(http.StatusOK, FlagSubmitResp{
- Message: "Your flag is correct",
+ c.JSON(http.StatusOK, ChallengeSubmitResponse{
+ Message: "Your challenge submission has been verified",
Success: true,
})
@@ -313,32 +283,66 @@ func submitFlagHandler(c *gin.Context) {
}
}
-// dynamicScore returns dynamic score of the challenge based on number of solves
-func dynamicScore(maxPoints, minPoints, solvers uint) uint {
- if solvers == 0 || solvers == 1 {
- return maxPoints
+func checkScriptExistence(localDeploy bool, instance *cache.Instance) (bool, error) {
+ var err error
+ var result cr.ExecResult
+
+ containerId := instance.ContainerID
+ fileCommand := fmt.Sprintf("[ -f '%s' ]", core.SAD_CHECK_SCRIPT_LOCATION)
+ if localDeploy {
+ result, err = cr.RunCommandInContainer(containerId, []string{
+ "sh", "-c", fileCommand,
+ })
+ } else {
+ server := manager.GetServerFromHost(instance.HostedAddress)
+ if server == nil {
+ return false, fmt.Errorf("server not found for host: %s", instance.HostedAddress)
+ }
+
+ result, err = remoteManager.RunCommandInContainerOnServer(*server, containerId, fileCommand)
+ }
+
+ if err != nil {
+ return false, err
+ } else if result.ExitCode != 0 {
+ return false, fmt.Errorf("failed to verify location of check.sh")
+ }
+
+ return true, nil
+}
+
+func validateCheckScriptHash(localDeploy bool, instance *cache.Instance) (bool, error) {
+ var err error
+ var result cr.ExecResult
+
+ containerId := instance.ContainerID
+ hashCommand := fmt.Sprintf("command cat %s | sha256sum", core.SAD_CHECK_SCRIPT_LOCATION)
+ if localDeploy {
+ result, err = cr.RunCommandInContainer(containerId, []string{
+ "sh", "-c", hashCommand,
+ })
+ } else {
+ server := config.Cfg.AvailableServers[instance.ServerDeployed]
+ result, err = remoteManager.RunCommandInContainerOnServer(server, containerId, hashCommand)
+ }
+
+ if err != nil {
+ return false, err
}
- divisor := (1 + math.Pow((float64(solvers)-1)/11.92201, 1.206069))
- return uint(math.Round(float64(minPoints) + (float64(maxPoints)-float64(minPoints))/divisor))
+ if result.ExitCode != 0 {
+ return false, fmt.Errorf("check script hash failed")
+ }
+
+ return strings.TrimSpace(result.Output) == instance.CheckHash, nil
}
-// updatePointsOfSolvers updates the points of solvers, whenever points of challenge changes
-func updatePointsOfSolvers(submissions []database.UserChallenges, newChallengePointsAfterSolve, oldChallengePointsBeforeSolve uint) error {
- for _, submission := range submissions {
- user, err := database.QueryUserById(submission.UserID)
- if err != nil {
- return err
- }
- if user.Role == "contestant" {
- newScore := user.Score + (newChallengePointsAfterSolve - oldChallengePointsBeforeSolve)
- if newScore <= 0 {
- newScore = 0
- }
- err = database.UpdateUser(&user, map[string]interface{}{"Score": newScore})
- if err != nil {
- return err
- }
- }
+func executeCheckScript(localDeploy bool, instance *cache.Instance) (cr.ExecResult, error) {
+ if localDeploy {
+ return cr.RunCommandInContainer(instance.ContainerID, []string{
+ "sh", "-c", core.SAD_CHECK_SCRIPT_LOCATION,
+ })
+ } else {
+ server := config.Cfg.AvailableServers[instance.HostedAddress]
+ return remoteManager.RunCommandInContainerOnServer(server, instance.ContainerID, core.SAD_CHECK_SCRIPT_LOCATION)
}
- return nil
}
diff --git a/api/info.go b/api/info.go
index 6c578298..c24d611b 100644
--- a/api/info.go
+++ b/api/info.go
@@ -29,24 +29,6 @@ var (
graphCacheStale = true
)
-// Returns port in use by beast.
-// @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.
-// @Description Returns the ports in use by beast, which cannot be used in creating a new challenge..
-// @Tags info
-// @Accept json
-// @Produce json
-// @Param Authorization header string true "Bearer"
-// @Success 200 {object} api.PortsInUseResp
-// @Router /api/info/ports/used [get]
-
-func usedPortsInfoHandler(c *gin.Context) {
- c.JSON(http.StatusOK, PortsInUseResp{
- MinPortValue: core.ALLOWED_MIN_PORT_VALUE,
- MaxPortValue: core.ALLOWED_MAX_PORT_VALUE,
- PortsInUse: cfg.USED_PORTS_LIST,
- })
-}
-
func hintHandler(c *gin.Context) {
hintIDStr := c.Param("hintID")
diff --git a/api/instance.go b/api/instance.go
new file mode 100644
index 00000000..ce06d5a6
--- /dev/null
+++ b/api/instance.go
@@ -0,0 +1,413 @@
+package api
+
+import (
+ "fmt"
+ "net/http"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/sdslabs/beastv4/core/cache"
+ "github.com/sdslabs/beastv4/core/config"
+ "github.com/sdslabs/beastv4/core/database"
+ "github.com/sdslabs/beastv4/core/manager"
+ coreUtils "github.com/sdslabs/beastv4/core/utils"
+)
+
+type InstanceResponse struct {
+ InstanceID string `json:"instance_id"`
+ ChallengeName string `json:"challenge_name"`
+ HostedAddress string `json:"hosted_address"`
+ Port uint32 `json:"port"`
+ CreatedAt time.Time `json:"created_at"`
+ ExpiresAt time.Time `json:"expires_at"`
+ TTLSeconds int64 `json:"ttl_seconds"`
+}
+
+type AdminInstanceResponse struct {
+ InstanceResponse
+ UserID string `json:"user_id"`
+ Username string `json:"username"`
+ ContainerID string `json:"container_id"`
+ DeploymentType string `json:"deployment_type"`
+}
+
+func instanceToResponse(instance *cache.Instance) InstanceResponse {
+ ttl := time.Until(instance.ExpiresAt).Seconds()
+ if ttl < 0 {
+ ttl = 0
+ }
+
+ return InstanceResponse{
+ InstanceID: instance.InstanceID,
+ ChallengeName: instance.ChallengeName,
+ HostedAddress: instance.HostedAddress,
+ Port: instance.Port,
+ CreatedAt: instance.CreatedAt,
+ ExpiresAt: instance.ExpiresAt,
+ TTLSeconds: int64(ttl),
+ }
+}
+
+func instanceToAdminResponse(instance *cache.Instance) AdminInstanceResponse {
+ return AdminInstanceResponse{
+ InstanceResponse: instanceToResponse(instance),
+ UserID: instance.UserID,
+ Username: instance.Username,
+ ContainerID: instance.ContainerID,
+ DeploymentType: instance.DeploymentType,
+ }
+}
+
+func spawnInstanceHandler(ctx *gin.Context) {
+ challengeName := ctx.Param("challenge_name")
+ if challengeName == "" {
+ ctx.JSON(http.StatusBadRequest, HTTPErrorResp{
+ Error: "challenge_name is required",
+ })
+ return
+ }
+
+ username, err := coreUtils.GetUser(ctx.GetHeader("Authorization"))
+ if err != nil {
+ ctx.JSON(http.StatusUnauthorized, HTTPPlainResp{
+ Message: "Unauthorized",
+ })
+ return
+ }
+
+ user, err := database.QueryFirstUserEntry("username", username)
+ if err != nil || user.ID == 0 {
+ ctx.JSON(http.StatusUnauthorized, HTTPPlainResp{
+ Message: "User not found",
+ })
+ return
+ }
+
+ userID := fmt.Sprintf("%d", user.ID)
+
+ instance, err := manager.SpawnInstance(challengeName, userID, username)
+ if err != nil {
+ if instance != nil {
+ ctx.JSON(http.StatusConflict, HTTPErrorResp{
+ Error: err.Error(),
+ })
+ return
+ }
+ ctx.JSON(http.StatusBadRequest, HTTPErrorResp{
+ Error: err.Error(),
+ })
+ return
+ }
+
+ ctx.JSON(http.StatusOK, instanceToResponse(instance))
+}
+
+func getUserInstanceHandler(ctx *gin.Context) {
+ challengeName := ctx.Param("challenge_name")
+ if challengeName == "" {
+ ctx.JSON(http.StatusBadRequest, HTTPErrorResp{
+ Error: "challenge_name is required",
+ })
+ return
+ }
+
+ username, err := coreUtils.GetUser(ctx.GetHeader("Authorization"))
+ if err != nil {
+ ctx.JSON(http.StatusUnauthorized, HTTPPlainResp{
+ Message: "Unauthorized",
+ })
+ return
+ }
+
+ user, err := database.QueryFirstUserEntry("username", username)
+ if err != nil || user.ID == 0 {
+ ctx.JSON(http.StatusUnauthorized, HTTPPlainResp{
+ Message: "User not found",
+ })
+ return
+ }
+
+ userID := fmt.Sprintf("%d", user.ID)
+
+ instance, err := manager.GetUserInstance(userID, challengeName)
+ if err != nil {
+ ctx.JSON(http.StatusNotFound, HTTPErrorResp{
+ Error: "No active instance found for this challenge",
+ })
+ return
+ }
+
+ ctx.JSON(http.StatusOK, instanceToResponse(instance))
+}
+
+func getUserInstancesHandler(ctx *gin.Context) {
+ username, err := coreUtils.GetUser(ctx.GetHeader("Authorization"))
+ if err != nil {
+ ctx.JSON(http.StatusUnauthorized, HTTPPlainResp{
+ Message: "Unauthorized",
+ })
+ return
+ }
+
+ user, err := database.QueryFirstUserEntry("username", username)
+ if err != nil || user.ID == 0 {
+ ctx.JSON(http.StatusUnauthorized, HTTPPlainResp{
+ Message: "User not found",
+ })
+ return
+ }
+
+ userID := fmt.Sprintf("%d", user.ID)
+
+ instances, err := manager.GetUserInstances(userID)
+ if err != nil {
+ ctx.JSON(http.StatusInternalServerError, HTTPErrorResp{
+ Error: err.Error(),
+ })
+ return
+ }
+
+ var response []InstanceResponse
+ for _, instance := range instances {
+ response = append(response, instanceToResponse(instance))
+ }
+
+ if response == nil {
+ response = []InstanceResponse{}
+ }
+
+ ctx.JSON(http.StatusOK, response)
+}
+
+func extendInstanceHandler(ctx *gin.Context) {
+ challengeName := ctx.Param("challenge_name")
+ if challengeName == "" {
+ ctx.JSON(http.StatusBadRequest, HTTPErrorResp{
+ Error: "challenge_name is required",
+ })
+ return
+ }
+
+ username, err := coreUtils.GetUser(ctx.GetHeader("Authorization"))
+ if err != nil {
+ ctx.JSON(http.StatusUnauthorized, HTTPPlainResp{
+ Message: "Unauthorized",
+ })
+ return
+ }
+
+ user, err := database.QueryFirstUserEntry("username", username)
+ if err != nil || user.ID == 0 {
+ ctx.JSON(http.StatusUnauthorized, HTTPPlainResp{
+ Message: "User not found",
+ })
+ return
+ }
+
+ userID := fmt.Sprintf("%d", user.ID)
+
+ instance, err := manager.GetUserInstance(userID, challengeName)
+ if err != nil {
+ ctx.JSON(http.StatusNotFound, HTTPErrorResp{
+ Error: "No active instance found for this challenge",
+ })
+ return
+ }
+
+ additionalSeconds := int64(300)
+ if seconds := ctx.PostForm("seconds"); seconds != "" {
+ var parsedSeconds int64
+ _, err := fmt.Sscanf(seconds, "%d", &parsedSeconds)
+ if err == nil && parsedSeconds > 0 {
+ additionalSeconds = parsedSeconds
+ }
+ }
+
+ maxExtension := config.Cfg.InstanceConfig.MaxExtension
+ if additionalSeconds > maxExtension {
+ additionalSeconds = maxExtension
+ }
+
+ err = manager.ExtendInstance(instance.InstanceID, additionalSeconds)
+ if err != nil {
+ ctx.JSON(http.StatusBadRequest, HTTPErrorResp{
+ Error: err.Error(),
+ })
+ return
+ }
+
+ instance, err = manager.GetUserInstance(userID, challengeName)
+ if err != nil {
+ ctx.JSON(http.StatusInternalServerError, HTTPErrorResp{
+ Error: "Failed to get updated instance",
+ })
+ return
+ }
+
+ ctx.JSON(http.StatusOK, instanceToResponse(instance))
+}
+
+func killUserInstanceHandler(ctx *gin.Context) {
+ challengeName := ctx.Param("challenge_name")
+ if challengeName == "" {
+ ctx.JSON(http.StatusBadRequest, HTTPErrorResp{
+ Error: "challenge_name is required",
+ })
+ return
+ }
+
+ username, err := coreUtils.GetUser(ctx.GetHeader("Authorization"))
+ if err != nil {
+ ctx.JSON(http.StatusUnauthorized, HTTPPlainResp{
+ Message: "Unauthorized",
+ })
+ return
+ }
+
+ user, err := database.QueryFirstUserEntry("username", username)
+ if err != nil || user.ID == 0 {
+ ctx.JSON(http.StatusUnauthorized, HTTPPlainResp{
+ Message: "User not found",
+ })
+ return
+ }
+
+ userID := fmt.Sprintf("%d", user.ID)
+
+ err = manager.KillUserInstance(userID, challengeName)
+ if err != nil {
+ ctx.JSON(http.StatusNotFound, HTTPErrorResp{
+ Error: err.Error(),
+ })
+ return
+ }
+
+ ctx.JSON(http.StatusOK, HTTPPlainResp{
+ Message: "Instance killed successfully",
+ })
+}
+
+func adminGetAllInstancesHandler(ctx *gin.Context) {
+ instances, err := manager.GetAllInstances()
+ if err != nil {
+ ctx.JSON(http.StatusInternalServerError, HTTPErrorResp{
+ Error: err.Error(),
+ })
+ return
+ }
+
+ var response []AdminInstanceResponse
+ for _, instance := range instances {
+ response = append(response, instanceToAdminResponse(instance))
+ }
+
+ if response == nil {
+ response = []AdminInstanceResponse{}
+ }
+
+ ctx.JSON(http.StatusOK, response)
+}
+
+func adminGetInstanceHandler(ctx *gin.Context) {
+ instanceID := ctx.Param("instance_id")
+ if instanceID == "" {
+ ctx.JSON(http.StatusBadRequest, HTTPErrorResp{
+ Error: "instance_id is required",
+ })
+ return
+ }
+
+ instance, err := manager.GetInstance(instanceID)
+ if err != nil {
+ ctx.JSON(http.StatusNotFound, HTTPErrorResp{
+ Error: "Instance not found",
+ })
+ return
+ }
+
+ ctx.JSON(http.StatusOK, instanceToAdminResponse(instance))
+}
+
+func adminKillInstanceHandler(ctx *gin.Context) {
+ instanceID := ctx.Param("instance_id")
+ if instanceID == "" {
+ ctx.JSON(http.StatusBadRequest, HTTPErrorResp{
+ Error: "instance_id is required",
+ })
+ return
+ }
+
+ err := manager.KillInstance(instanceID)
+ if err != nil {
+ ctx.JSON(http.StatusNotFound, HTTPErrorResp{
+ Error: err.Error(),
+ })
+ return
+ }
+
+ ctx.JSON(http.StatusOK, HTTPPlainResp{
+ Message: "Instance killed successfully",
+ })
+}
+
+func adminKillUserInstancesHandler(ctx *gin.Context) {
+ userID := ctx.Param("user_id")
+ if userID == "" {
+ ctx.JSON(http.StatusBadRequest, HTTPErrorResp{
+ Error: "user_id is required",
+ })
+ return
+ }
+
+ instances, err := manager.GetUserInstances(userID)
+ if err != nil {
+ ctx.JSON(http.StatusInternalServerError, HTTPErrorResp{
+ Error: err.Error(),
+ })
+ return
+ }
+
+ killedCount := 0
+ for _, instance := range instances {
+ err := manager.KillInstance(instance.InstanceID)
+ if err == nil {
+ killedCount++
+ }
+ }
+
+ ctx.JSON(http.StatusOK, HTTPPlainResp{
+ Message: fmt.Sprintf("%d instances killed", killedCount),
+ })
+}
+
+func adminKillChallengeInstancesHandler(ctx *gin.Context) {
+ challengeName := ctx.Param("challenge_name")
+ if challengeName == "" {
+ ctx.JSON(http.StatusBadRequest, HTTPErrorResp{
+ Error: "challenge_name is required",
+ })
+ return
+ }
+
+ instances, err := manager.GetAllInstances()
+ if err != nil {
+ ctx.JSON(http.StatusInternalServerError, HTTPErrorResp{
+ Error: err.Error(),
+ })
+ return
+ }
+
+ killedCount := 0
+ for _, instance := range instances {
+ if instance.ChallengeName == challengeName {
+ err := manager.KillInstance(instance.InstanceID)
+ if err == nil {
+ killedCount++
+ }
+ }
+ }
+
+ ctx.JSON(http.StatusOK, HTTPPlainResp{
+ Message: fmt.Sprintf("%d instances killed", killedCount),
+ })
+}
diff --git a/api/main.go b/api/main.go
index 8cfd5e55..1217dfaa 100644
--- a/api/main.go
+++ b/api/main.go
@@ -4,6 +4,7 @@ import (
"net/http"
"github.com/gin-gonic/gin"
+ "github.com/sdslabs/beastv4/core/cache"
log "github.com/sirupsen/logrus"
ginSwagger "github.com/swaggo/gin-swagger"
swaggerFiles "github.com/swaggo/gin-swagger/swaggerFiles"
@@ -67,6 +68,7 @@ func RunBeastApiServer(port, defaultauthorpassword string, autoDeploy, healthPro
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()
// Initialise and start the Hub
// Must be started before the Notification Router, since SSE handler has access to SSE Hub
diff --git a/api/response.go b/api/response.go
index dcad44c8..4dbeccc0 100644
--- a/api/response.go
+++ b/api/response.go
@@ -106,16 +106,18 @@ 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']"`
- Points uint `json:"points" example:"50"`
- Difficulty string `json:"difficulty" example:"easy"` // e.g., "easy", "medium", "hard"
- SolvesNumber uint16 `json:"solvesNumber" example:"100"`
- SolveStatus bool `json:"solveStatus" example:"True"` // e.g., True: "solved", False: "unsolved"
- CreatedAt time.Time `json:"createdAt"`
- DeployedStatus string `json:"deployedStatus" example:"deployed"`
- PreRequisite []string `json:"preRequisite" example:"['chall1', chall2]"`
+ ChallId uint `json:"id" example:"0"`
+ Name string `json:"name" example:"Web Challenge"`
+ 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]"`
+ Instanced bool `json:"instanced" example:"false"`
+ InstanceExpiration int64 `json:"instanceExpiration" example:"300"`
}
type Challenge struct {
@@ -164,7 +166,7 @@ type SubmissionResp struct {
SolvedAt time.Time `json:"solvedAt"`
}
-type FlagSubmitResp struct {
+type ChallengeSubmitResponse struct {
Message string `json:"message" example:"Your answer is correct"`
Success bool `json:"success" example:"true"`
}
diff --git a/api/router.go b/api/router.go
index 9ebe114e..ca7046d7 100644
--- a/api/router.go
+++ b/api/router.go
@@ -118,9 +118,9 @@ func initGinRouter() *gin.Engine {
configGroup.POST("/challenge-info", updateChallengeInfoHandler)
}
- submitGroup := apiGroup.Group("/submit")
+ submitGroup := apiGroup.Group("/check")
{
- submitGroup.POST("/challenge", submitFlagHandler)
+ submitGroup.POST("/challenge", checkFlagHandler)
}
adminPanelGroup := apiGroup.Group("/admin", adminAuthorize)
@@ -132,6 +132,20 @@ func initGinRouter() *gin.Engine {
adminPanelGroup.POST("/unfreezeLeaderboard", unfreezeLeaderboardHandler)
adminPanelGroup.GET("/challenges/:challenge_id/attempts", getChallengeAttempts)
+ adminPanelGroup.GET("/instances", adminGetAllInstancesHandler)
+ adminPanelGroup.GET("/instances/:instance_id", adminGetInstanceHandler)
+ adminPanelGroup.DELETE("/instances/:instance_id", adminKillInstanceHandler)
+ adminPanelGroup.DELETE("/instances/user/:user_id", adminKillUserInstancesHandler)
+ adminPanelGroup.DELETE("/instances/challenge/:challenge_name", adminKillChallengeInstancesHandler)
+ }
+
+ instanceGroup := apiGroup.Group("/instances")
+ {
+ instanceGroup.GET("", getUserInstancesHandler)
+ instanceGroup.GET("/:challenge_name", getUserInstanceHandler)
+ instanceGroup.POST("/:challenge_name/spawn", spawnInstanceHandler)
+ instanceGroup.POST("/:challenge_name/extend", extendInstanceHandler)
+ instanceGroup.DELETE("/:challenge_name", killUserInstanceHandler)
}
}
diff --git a/cmd/beast/backup.go b/cmd/beast/backup.go
index 14e088af..59b1e2f7 100644
--- a/cmd/beast/backup.go
+++ b/cmd/beast/backup.go
@@ -1,6 +1,7 @@
package main
import (
+ "github.com/sdslabs/beastv4/core/cache"
"github.com/sdslabs/beastv4/core/database"
"github.com/spf13/cobra"
)
@@ -12,3 +13,11 @@ var backupDatabase = &cobra.Command{
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()
+ },
+}
diff --git a/cmd/beast/cache.go b/cmd/beast/cache.go
new file mode 100644
index 00000000..c3b9086c
--- /dev/null
+++ b/cmd/beast/cache.go
@@ -0,0 +1,30 @@
+package main
+
+import (
+ "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()
+ },
+}
+
+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.")
+ }
+ },
+}
diff --git a/cmd/beast/commands.go b/cmd/beast/commands.go
index 05f1ecda..6ca5467d 100644
--- a/cmd/beast/commands.go
+++ b/cmd/beast/commands.go
@@ -113,6 +113,9 @@ func init() {
restoreDatabaseCmd.PersistentFlags().StringVarP(&RestoreFile, "restore-file", "r", "", "Backup file to be used for restoration.")
+
+ restoreCacheCmd.PersistentFlags().StringVarP(&RestoreFile, "restore-file", "r", "", "Restore file to be used for restoration.")
+
rootCmd.AddCommand(versionCmd)
rootCmd.AddCommand(initCmd)
rootCmd.AddCommand(configCmd)
@@ -131,4 +134,7 @@ func init() {
rootCmd.AddCommand(resetDatabaseCmd)
rootCmd.AddCommand(restoreDatabaseCmd)
rootCmd.AddCommand(backupDatabase)
+ rootCmd.AddCommand(resetCacheCmd)
+ rootCmd.AddCommand(restoreCacheCmd)
+ rootCmd.AddCommand(backupCache)
}
diff --git a/cmd/beast/config.go b/cmd/beast/config.go
index 7868640d..91d964cf 100644
--- a/cmd/beast/config.go
+++ b/cmd/beast/config.go
@@ -3,18 +3,19 @@ package main
import (
"errors"
"fmt"
- "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"
+
+ "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"
)
var (
@@ -192,6 +193,30 @@ func promptNotificationWebhooks(configuration *config.BeastConfig) {
}
}
+func promptCacheConnectionDetails(configuration *config.BeastConfig) {
+ log.Warnln("Beast expects Redis ACLs to be enabled. If ACLs are not configured, some features may not function correctly.")
+
+ 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))
+ if configuration.RedisConf.Password == "" {
+ configuration.RedisConf.Password = "beast"
+ }
+
+ configuration.RedisConf.Host = utils.PromptString("Enter Redis Host Name, leave empty for localhost")
+ if configuration.RedisConf.Host == "" {
+ configuration.RedisConf.Host = core.LOCALHOST
+ }
+
+ configuration.RedisConf.Port = strconv.FormatInt(utils.PromptInt64("Enter Redis Port", 6379), 10)
+
+ log.Infoln("Setting Redis DB to 0...")
+ configuration.RedisConf.Db = 0
+}
+
func promptDatabaseConnectionDetails(configuration *config.BeastConfig) {
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 == "" {
@@ -210,7 +235,7 @@ func promptDatabaseConnectionDetails(configuration *config.BeastConfig) {
configuration.PsqlConf.Host = utils.PromptString("Enter Postgres Host Name, leave empty for localhost")
if configuration.PsqlConf.Host == "" {
- configuration.PsqlConf.Host = "localhost"
+ configuration.PsqlConf.Host = core.LOCALHOST
}
configuration.PsqlConf.Port = strconv.FormatInt(utils.PromptInt64("Enter Postgres Port", 5432), 10)
configuration.PsqlConf.SslMode = utils.PromptSelection("Enter Postgres SSL Mode", []string{
@@ -227,7 +252,11 @@ func promptBeastConfiguration(configuration *config.BeastConfig) {
promptRemoteRepository(configuration)
promptCompetitionDetails(configuration)
promptNotificationWebhooks(configuration)
+ promptCacheConnectionDetails(configuration)
promptDatabaseConnectionDetails(configuration)
+
+ log.Infoln("Enabling healthcheck for instance on demand containers")
+ configuration.HealthProber = true
}
func tryCopyExampleConfig() error {
diff --git a/cmd/beast/init.go b/cmd/beast/init.go
index 90a0dd6f..ca62bcea 100644
--- a/cmd/beast/init.go
+++ b/cmd/beast/init.go
@@ -1,12 +1,21 @@
package main
import (
+ "context"
"database/sql"
"errors"
"fmt"
- "github.com/BurntSushi/toml"
+ "io"
+ "net/http"
+ "os"
+ "os/exec"
+ "os/user"
+ "path/filepath"
+ "strings"
+
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/lib/pq"
+ "github.com/redis/go-redis/v9"
"github.com/sdslabs/beastv4/core"
"github.com/sdslabs/beastv4/core/config"
"github.com/sdslabs/beastv4/core/database"
@@ -14,13 +23,6 @@ import (
"github.com/sdslabs/beastv4/utils"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
- "io"
- "net/http"
- "os"
- "os/exec"
- "os/user"
- "path/filepath"
- "strings"
)
const (
@@ -95,6 +97,62 @@ func installAir() error {
return cmd.Run()
}
+func createBeastRedisUser(cache *redis.Client, configuration *config.RedisConfig) error {
+ ctx := context.Background()
+
+ result, err := cache.ACLUsers(ctx).Result()
+ if err != nil {
+ return err
+ }
+
+ for _, user := range result {
+ if user == configuration.User {
+ log.Infoln(fmt.Sprintf("Redis user %s already exists", configuration.User))
+ break
+ }
+ }
+
+ _, err = cache.ACLSetUser(ctx, configuration.User, "on", ">"+configuration.Password, "~host:*", "~beast:*", "+@all").Result()
+ if err != nil {
+ return err
+ }
+ log.Infoln(fmt.Sprintf("Initialised redis user %s", configuration.User))
+
+ err = cache.Do(ctx, "acl", "save").Err()
+ if err != nil {
+ return fmt.Errorf("error while trying to save the acl file: %s", err.Error())
+ }
+
+ return nil
+}
+
+func initCache() error {
+ log.Infoln("Initializing cache...")
+
+ redisConfig := config.Cfg.RedisConf
+ 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"),
+ })
+ } else {
+ cache = redis.NewClient(&redis.Options{
+ Addr: fmt.Sprintf("%s:%s", redisConfig.Host, redisConfig.Port),
+ Username: core.REDIS_DEFAULT_USER,
+ })
+ }
+
+ _, err := cache.Ping(context.Background()).Result()
+ if err != nil {
+ return fmt.Errorf("failed to connected to redis: %s", err.Error())
+ }
+
+ defer cache.Close()
+ return createBeastRedisUser(cache, &config.Cfg.RedisConf)
+}
+
func createBeastDbUser(db *sql.DB, configuration *config.PsqlConfig) error {
if result := utils.PromptBinary("Create default beast postgres user?"); !result {
return errors.New("failed to create database")
@@ -125,22 +183,18 @@ func dbUserCheck() (bool, error) {
func initDb() error {
log.Infoln("Initializing database...")
- var configuration config.BeastConfig
- _, err := toml.DecodeFile(BEAST_GLOBAL_CONFIG, &configuration)
- if err != nil {
- return err
- }
-
isPostgres, err := dbUserCheck()
if err != nil {
return err
}
+ configuration := config.Cfg.PsqlConf
+
var db *sql.DB
if isPostgres {
log.Infoln("Attempting to connect to postgres as postgres super user...")
- dsn := fmt.Sprintf("user=%s dbname=%s sslmode=%s", "postgres", "postgres", "disable")
+ dsn := fmt.Sprintf("user=%s dbname=%s host=%s port=%s sslmode=%s", "postgres", "postgres", configuration.Host, configuration.Port, "disable")
db, err = sql.Open("pgx", dsn)
if err != nil {
@@ -152,7 +206,7 @@ func initDb() error {
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")
+ dsn := fmt.Sprintf("user=%s password=%s dbname=%s host=%s port=%s sslmode=%s", "postgres", password, "postgres", configuration.Host, configuration.Port, "disable")
db, err = sql.Open("pgx", dsn)
if err != nil {
@@ -167,41 +221,41 @@ func initDb() error {
defer db.Close()
var exists int
- err = db.QueryRow("SELECT 1 FROM pg_roles WHERE rolname = $1", configuration.PsqlConf.User).Scan(&exists)
+ err = db.QueryRow("SELECT 1 FROM pg_roles WHERE rolname = $1", configuration.User).Scan(&exists)
if errors.Is(err, sql.ErrNoRows) {
- if err = createBeastDbUser(db, &configuration.PsqlConf); err != nil {
+ if err = createBeastDbUser(db, &configuration); err != nil {
return err
}
} else if err != nil {
return err
} else {
- log.Infoln(fmt.Sprintf("User %s already exists", configuration.PsqlConf.User))
+ log.Infoln(fmt.Sprintf("User %s already exists", configuration.User))
}
- log.Infoln(fmt.Sprintf("Changing password for user %s", configuration.PsqlConf.User))
- query := fmt.Sprintf("ALTER USER %s WITH PASSWORD %s", pq.QuoteIdentifier(configuration.PsqlConf.User), utils.QuoteLiteral(configuration.PsqlConf.Password))
+ log.Infoln(fmt.Sprintf("Changing password for user %s", configuration.User))
+ query := fmt.Sprintf("ALTER USER %s WITH PASSWORD %s", pq.QuoteIdentifier(configuration.User), utils.QuoteLiteral(configuration.Password))
_, err = db.Exec(query)
if err != nil {
return err
}
- err = db.QueryRow("SELECT 1 FROM pg_database WHERE datname = $1", configuration.PsqlConf.Dbname).Scan(&exists)
+ err = db.QueryRow("SELECT 1 FROM pg_database WHERE datname = $1", configuration.Dbname).Scan(&exists)
if errors.Is(err, sql.ErrNoRows) {
- if err = createBeastDatabase(db, &configuration.PsqlConf); err != nil {
+ if err = createBeastDatabase(db, &configuration); err != nil {
return err
}
} else if err != nil {
return err
} else {
- log.Infoln(fmt.Sprintf("Database %s already exists", configuration.PsqlConf.Dbname))
+ log.Infoln(fmt.Sprintf("Database %s already exists", configuration.Dbname))
}
- _, err = db.Exec(fmt.Sprintf("ALTER DATABASE %s OWNER TO %s", pq.QuoteIdentifier(configuration.PsqlConf.Dbname), pq.QuoteIdentifier(configuration.PsqlConf.User)))
+ _, err = db.Exec(fmt.Sprintf("ALTER DATABASE %s OWNER TO %s", pq.QuoteIdentifier(configuration.Dbname), pq.QuoteIdentifier(configuration.User)))
if err != nil {
return err
}
- log.Infoln(fmt.Sprintf("%s set as owner of database %s", configuration.PsqlConf.User, configuration.PsqlConf.Dbname))
+ log.Infoln(fmt.Sprintf("%s set as owner of database %s", configuration.User, configuration.Dbname))
return nil
}
@@ -273,6 +327,17 @@ func runBeastBootsteps() error {
log.Infoln("Successfully installed air for live reloading...")
+ err := config.ReloadBeastConfig()
+ if err != nil {
+ return err
+ }
+
+ if err := initCache(); err != nil {
+ return err
+ }
+
+ log.Infoln("Verified redis setup for beast")
+
if err := initDb(); err != nil {
return err
}
diff --git a/cmd/beast/run.go b/cmd/beast/run.go
index d389ebc1..7592e4bd 100644
--- a/cmd/beast/run.go
+++ b/cmd/beast/run.go
@@ -3,6 +3,7 @@ package main
import (
"encoding/json"
"fmt"
+ "github.com/sdslabs/beastv4/core/cache"
"math"
"os"
"os/signal"
@@ -66,6 +67,26 @@ func cleanupRunningContainers() {
}
}
+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.TerminateCacheConnections()
+ if err != nil {
+ log.Errorln("Unable to terminate cache connections:", err)
+ } else {
+ log.Infoln("Cache connections terminated successfully")
+ }
+}
+
func cleanupDatabaseConnections() {
log.Infoln("Backing up database...")
@@ -137,6 +158,8 @@ func cleanup() {
saveLeaderboardCache()
cleanupRunningContainers()
+
+ cleanupCacheConnections()
cleanupDatabaseConnections()
// - Clean up temporary files: found no files to be cleared as of now
diff --git a/core/cache/cache.go b/core/cache/cache.go
new file mode 100644
index 00000000..6cd3f629
--- /dev/null
+++ b/core/cache/cache.go
@@ -0,0 +1,263 @@
+package cache
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "sync"
+ "time"
+
+ "github.com/BurntSushi/toml"
+ "github.com/redis/go-redis/v9"
+ "github.com/sdslabs/beastv4/core"
+ "github.com/sdslabs/beastv4/utils"
+ log "github.com/sirupsen/logrus"
+)
+
+var (
+ CacheMutex *sync.Mutex
+ Cache *redis.Client
+ cacheError error
+)
+
+var (
+ BEAST_GLOBAL_DIR string = filepath.Join(os.Getenv("HOME"), ".beast")
+ cacheConfig Config
+)
+
+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"`
+}
+
+// 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)
+ }
+}
+
+// Connect redis
+func ConnectRedis() 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,
+ })
+
+ _, err := Cache.Ping(context.Background()).Result()
+ if err != nil {
+ return fmt.Errorf("failed to connected to redis: %s", err.Error())
+ }
+
+ log.Debug("Cache initialized")
+ return nil
+}
+
+// Set up the initial bootstrapping for interacting with the
+// 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{}
+ if Cache == nil {
+ cacheError = ConnectRedis()
+ if cacheError != nil {
+ log.Errorf("Error while initializing cache: %s", cacheError.Error())
+ }
+ }
+}
+
+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, "backup", 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
+ }
+
+ backupPath = filepath.Join(core.BEAST_GLOBAL_DIR, "backup", core.BEAST_STAGING_DIR)
+
+ err = utils.CreateIfNotExistDir(backupPath)
+ if err != nil {
+ log.Errorf("Error while creating backup directory: %s", err)
+ return
+ }
+
+ 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
+ }
+}
+
+func BackupCache() error {
+ if cacheConfig == (Config{}) {
+ LoadCacheConfig()
+ }
+
+ backupPath := filepath.Join(core.BEAST_GLOBAL_DIR, "backup", "cache")
+ err := utils.CreateIfNotExistDir(backupPath)
+ if err != nil {
+ log.Errorf("Error while creating backup directory: %s", err)
+ 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)
+ }
+
+ cmd := exec.Command("redis-cli", args...)
+
+ cmd.Env = append(os.Environ(), fmt.Sprintf("REDISCLI_AUTH=%s", cacheConfig.RedisConfig.Password))
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ log.Printf("Backup error: %s\n", string(output))
+ return err
+ }
+ 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
+ }
+
+ 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()
+ if err != nil {
+ log.Printf("Drop Cache error: %s\n", string(output))
+ return err
+ }
+
+ log.Debug("Reset successful.")
+ return nil
+}
+
+// Terminate all active connections before dropping
+func TerminateCacheConnections() error {
+ if cacheConfig == (Config{}) {
+ LoadCacheConfig()
+ }
+ terminateCmd := exec.Command(
+ "redis-cli",
+ "-h", cacheConfig.RedisConfig.Host,
+ "-p", cacheConfig.RedisConfig.Port,
+ "--user", cacheConfig.RedisConfig.User,
+ "-n", strconv.Itoa(cacheConfig.RedisConfig.DB),
+ "CLIENT", "KILL", "USER", cacheConfig.RedisConfig.User,
+ )
+
+ terminateCmd.Env = append(os.Environ(), fmt.Sprintf("REDISCLI_AUTH=%s", cacheConfig.RedisConfig.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 RestoreCache(backupFile string) error {
+ LoadCacheConfig()
+
+ err := TerminateCacheConnections()
+ if err != nil {
+ log.Errorf("Unable to terminate connections: %s ", err)
+ return err
+ }
+
+ err = utils.ValidateFileExists(backupFile)
+ if err != nil {
+ return fmt.Errorf("backup file does not exist: %s", backupFile)
+ }
+
+ // TODO: figure out how to do this
+ //restoreCmd := exec.Command(
+ // "pg_restore",
+ // "-U", dbConfig.PsqlConf.User,
+ // "-h", dbConfig.PsqlConf.Host,
+ // "-p", dbConfig.PsqlConf.Port,
+ // "-d", dbConfig.PsqlConf.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 cache error: %s\n", string(output))
+ // return fmt.Errorf("failed to restore cache from %s: %v", backupFile, err)
+ //}
+
+ log.Println("Cache restored successfully from:", backupFile)
+ return nil
+}
diff --git a/core/cache/instance.go b/core/cache/instance.go
new file mode 100644
index 00000000..8568f592
--- /dev/null
+++ b/core/cache/instance.go
@@ -0,0 +1,484 @@
+package cache
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+)
+
+type Instance struct {
+ InstanceID string `json:"instance_id"`
+ ChallengeName string `json:"challenge_name"`
+ ContainerID string `json:"container_id"`
+ HostedAddress string `json:"hosted_address"`
+ CheckHash string `json:"check_hash"`
+ Port uint32 `json:"port"`
+ UserID string `json:"user_id"`
+ Username string `json:"username"`
+ CreatedAt time.Time `json:"created_at"`
+ ExpiresAt time.Time `json:"expires_at"`
+ DeploymentType string `json:"deployment_type"`
+ ServerDeployed string `json:"server_deployed"`
+}
+
+const (
+ InstanceKeyPrefix = "beast:instance:"
+ UserInstanceKeyPrefix = "beast:user_instance:"
+ InstancesSetKey = "beast:instances"
+ InstanceDeletionQueue = "beast:instances:to_delete"
+)
+
+func instanceKey(instanceID string) string {
+ return InstanceKeyPrefix + instanceID
+}
+
+func userInstanceKey(userID, challengeName string) string {
+ return UserInstanceKeyPrefix + userID + ":" + challengeName
+}
+
+func SaveInstance(instance *Instance, ttl time.Duration) error {
+ if Cache == nil {
+ return fmt.Errorf("redis cache not initialized")
+ }
+
+ ctx := context.Background()
+ CacheMutex.Lock()
+ defer CacheMutex.Unlock()
+
+ data, err := json.Marshal(instance)
+ if err != nil {
+ return fmt.Errorf("failed to marshal instance: %w", err)
+ }
+
+ key := instanceKey(instance.InstanceID)
+ err = Cache.Set(ctx, key, data, ttl).Err()
+ if err != nil {
+ return fmt.Errorf("failed to save instance: %w", err)
+ }
+
+ userKey := userInstanceKey(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, InstancesSetKey, instance.InstanceID).Err()
+ if err != nil {
+ log.Warnf("failed to add instance to set: %v", err)
+ }
+
+ log.Debugf("Saved instance %s for user %s, challenge %s, port %d, expires in %v",
+ instance.InstanceID, instance.UserID, instance.ChallengeName, instance.Port, ttl)
+
+ return nil
+}
+
+func GetInstance(instanceID string) (*Instance, error) {
+ if Cache == nil {
+ return nil, fmt.Errorf("redis cache not initialized")
+ }
+
+ ctx := context.Background()
+ CacheMutex.Lock()
+ defer CacheMutex.Unlock()
+
+ key := instanceKey(instanceID)
+ data, err := Cache.Get(ctx, key).Bytes()
+ if err != nil {
+ return nil, fmt.Errorf("instance not found: %w", err)
+ }
+
+ var instance Instance
+ err = json.Unmarshal(data, &instance)
+ if err != nil {
+ return nil, fmt.Errorf("failed to unmarshal instance: %w", err)
+ }
+
+ return &instance, nil
+}
+
+func GetUserInstance(userID, challengeName string) (*Instance, error) {
+ if Cache == nil {
+ return nil, fmt.Errorf("redis cache not initialized")
+ }
+
+ ctx := context.Background()
+ CacheMutex.Lock()
+ defer CacheMutex.Unlock()
+
+ userKey := userInstanceKey(userID, challengeName)
+ instanceID, err := Cache.Get(ctx, userKey).Result()
+ if err != nil {
+ return nil, fmt.Errorf("user instance not found: %w", err)
+ }
+
+ key := instanceKey(instanceID)
+ data, err := Cache.Get(ctx, key).Bytes()
+ if err != nil {
+ return nil, fmt.Errorf("instance not found: %w", err)
+ }
+
+ var instance Instance
+ err = json.Unmarshal(data, &instance)
+ if err != nil {
+ return nil, fmt.Errorf("failed to unmarshal instance: %w", err)
+ }
+
+ return &instance, nil
+}
+
+func GetUserInstances(userID string) ([]*Instance, error) {
+ if Cache == nil {
+ return nil, fmt.Errorf("redis cache not initialized")
+ }
+
+ ctx := context.Background()
+ CacheMutex.Lock()
+ defer CacheMutex.Unlock()
+
+ pattern := UserInstanceKeyPrefix + userID + ":*"
+ var instances []*Instance
+
+ iter := Cache.Scan(ctx, 0, pattern, 0).Iterator()
+ for iter.Next(ctx) {
+ userKey := iter.Val()
+ instanceID, err := Cache.Get(ctx, userKey).Result()
+ if err != nil {
+ continue
+ }
+
+ key := instanceKey(instanceID)
+ data, err := Cache.Get(ctx, key).Bytes()
+ if err != nil {
+ continue
+ }
+
+ var instance Instance
+ err = json.Unmarshal(data, &instance)
+ if err != nil {
+ continue
+ }
+
+ instances = append(instances, &instance)
+ }
+
+ return instances, nil
+}
+
+func GetAllInstances() ([]*Instance, error) {
+ if Cache == nil {
+ return nil, fmt.Errorf("redis cache not initialized")
+ }
+
+ ctx := context.Background()
+ CacheMutex.Lock()
+ defer CacheMutex.Unlock()
+
+ instanceIDs, err := Cache.SMembers(ctx, InstancesSetKey).Result()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get instance IDs: %w", err)
+ }
+
+ var instances []*Instance
+ for _, id := range instanceIDs {
+ key := instanceKey(id)
+ data, err := Cache.Get(ctx, key).Bytes()
+ if err != nil {
+ Cache.SRem(ctx, InstancesSetKey, id)
+ continue
+ }
+
+ var instance Instance
+ err = json.Unmarshal(data, &instance)
+ if err != nil {
+ continue
+ }
+
+ instances = append(instances, &instance)
+ }
+
+ return instances, nil
+}
+
+// GetChallengeInstances retrieves all active instances for a specific challenge
+func GetChallengeInstances(challengeName string) ([]*Instance, error) {
+ if Cache == nil {
+ return nil, fmt.Errorf("redis cache not initialized")
+ }
+
+ ctx := context.Background()
+ CacheMutex.Lock()
+ defer CacheMutex.Unlock()
+
+ instanceIDs, err := Cache.SMembers(ctx, InstancesSetKey).Result()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get instance IDs: %w", err)
+ }
+
+ var instances []*Instance
+ for _, id := range instanceIDs {
+ key := instanceKey(id)
+ data, err := Cache.Get(ctx, key).Bytes()
+ if err != nil {
+ Cache.SRem(ctx, InstancesSetKey, id)
+ continue
+ }
+
+ var instance Instance
+ err = json.Unmarshal(data, &instance)
+ if err != nil {
+ continue
+ }
+
+ if instance.ChallengeName == challengeName {
+ instances = append(instances, &instance)
+ }
+ }
+
+ return instances, nil
+}
+
+func DeleteInstance(instanceID string) error {
+ if Cache == nil {
+ return fmt.Errorf("redis cache not initialized")
+ }
+
+ ctx := context.Background()
+ CacheMutex.Lock()
+ defer CacheMutex.Unlock()
+
+ key := instanceKey(instanceID)
+ data, err := Cache.Get(ctx, key).Bytes()
+ if err != nil {
+ return fmt.Errorf("instance not found: %w", err)
+ }
+
+ var instance Instance
+ err = json.Unmarshal(data, &instance)
+ if err != nil {
+ 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 := userInstanceKey(instance.UserID, instance.ChallengeName)
+ Cache.Del(ctx, userKey)
+ Cache.SRem(ctx, InstancesSetKey, instanceID)
+
+ log.Debugf("Deleted instance %s for user %s, challenge %s",
+ instanceID, instance.UserID, instance.ChallengeName)
+
+ return nil
+}
+
+func ExtendInstance(instanceID string, additionalTime time.Duration) error {
+ if Cache == nil {
+ return fmt.Errorf("redis cache not initialized")
+ }
+
+ ctx := context.Background()
+ CacheMutex.Lock()
+ defer CacheMutex.Unlock()
+
+ key := instanceKey(instanceID)
+
+ data, err := Cache.Get(ctx, key).Bytes()
+ if err != nil {
+ return fmt.Errorf("instance not found: %w", err)
+ }
+
+ var instance Instance
+ err = json.Unmarshal(data, &instance)
+ if err != nil {
+ return fmt.Errorf("failed to unmarshal instance: %w", err)
+ }
+
+ newExpiresAt := instance.ExpiresAt.Add(additionalTime)
+ instance.ExpiresAt = newExpiresAt
+
+ newTTL := time.Until(newExpiresAt)
+ if newTTL <= 0 {
+ return fmt.Errorf("instance has already expired")
+ }
+
+ updatedData, err := json.Marshal(instance)
+ if err != nil {
+ return fmt.Errorf("failed to marshal instance: %w", err)
+ }
+
+ err = Cache.Set(ctx, key, updatedData, newTTL).Err()
+ if err != nil {
+ return fmt.Errorf("failed to extend instance: %w", err)
+ }
+
+ userKey := userInstanceKey(instance.UserID, instance.ChallengeName)
+ Cache.Expire(ctx, userKey, newTTL)
+
+ log.Debugf("Extended instance %s by %v, new expiration: %v", instanceID, additionalTime, newExpiresAt)
+
+ return nil
+}
+
+func CountUserInstances(userID string) (int, error) {
+ if Cache == nil {
+ return 0, fmt.Errorf("redis cache not initialized")
+ }
+
+ ctx := context.Background()
+ CacheMutex.Lock()
+ defer CacheMutex.Unlock()
+
+ pattern := UserInstanceKeyPrefix + userID + ":*"
+ count := 0
+
+ iter := Cache.Scan(ctx, 0, pattern, 0).Iterator()
+ for iter.Next(ctx) {
+ count++
+ }
+
+ return count, nil
+}
+
+func GetInstanceTTL(instanceID string) (time.Duration, error) {
+ if Cache == nil {
+ return 0, fmt.Errorf("redis cache not initialized")
+ }
+
+ ctx := context.Background()
+ CacheMutex.Lock()
+ defer CacheMutex.Unlock()
+
+ key := instanceKey(instanceID)
+ ttl, err := Cache.TTL(ctx, key).Result()
+ if err != nil {
+ return 0, fmt.Errorf("failed to get TTL: %w", err)
+ }
+
+ return ttl, nil
+}
+
+func QueueInstanceForDeletion(instanceID string) error {
+ if Cache == nil {
+ return fmt.Errorf("redis cache not initialized")
+ }
+
+ ctx := context.Background()
+ CacheMutex.Lock()
+ defer CacheMutex.Unlock()
+
+ key := instanceKey(instanceID)
+
+ data, err := Cache.Get(ctx, key).Bytes()
+ if err != nil {
+ Cache.SRem(ctx, InstancesSetKey, instanceID)
+ return fmt.Errorf("instance not found: %w", err)
+ }
+
+ var instance Instance
+ err = json.Unmarshal(data, &instance)
+ if err != nil {
+ return fmt.Errorf("failed to unmarshal instance: %w", err)
+ }
+
+ pipe := Cache.TxPipeline()
+ pipe.LPush(ctx, InstanceDeletionQueue, data)
+ pipe.SRem(ctx, InstancesSetKey, instanceID)
+
+ userKey := userInstanceKey(instance.UserID, instance.ChallengeName)
+ pipe.Del(ctx, userKey)
+ pipe.Del(ctx, key)
+
+ _, err = pipe.Exec(ctx)
+ if err != nil {
+ return fmt.Errorf("failed to queue instance for deletion: %w", err)
+ }
+
+ log.Debugf("Queued instance %s for deletion (user: %s, challenge: %s)",
+ instanceID, instance.UserID, instance.ChallengeName)
+
+ return nil
+}
+
+func PopInstanceForDeletion() (*Instance, error) {
+ if Cache == nil {
+ return nil, fmt.Errorf("redis cache not initialized")
+ }
+
+ ctx := context.Background()
+ CacheMutex.Lock()
+ defer CacheMutex.Unlock()
+
+ data, err := Cache.RPop(ctx, InstanceDeletionQueue).Bytes()
+ if err != nil {
+ if err.Error() == "redis: nil" {
+ return nil, nil
+ }
+ return nil, fmt.Errorf("failed to pop from deletion queue: %w", err)
+ }
+
+ var instance Instance
+ err = json.Unmarshal(data, &instance)
+ if err != nil {
+ return nil, fmt.Errorf("failed to unmarshal instance from queue: %w", err)
+ }
+
+ log.Debugf("Popped instance %s from deletion queue", instance.InstanceID)
+ return &instance, nil
+}
+
+func GetDeletionQueueLength() (int64, error) {
+ if Cache == nil {
+ return 0, fmt.Errorf("redis cache not initialized")
+ }
+
+ ctx := context.Background()
+ CacheMutex.Lock()
+ defer CacheMutex.Unlock()
+
+ return Cache.LLen(ctx, InstanceDeletionQueue).Result()
+}
+
+func GetExpiredInstances() ([]*Instance, error) {
+ if Cache == nil {
+ return nil, fmt.Errorf("redis cache not initialized")
+ }
+
+ ctx := context.Background()
+ CacheMutex.Lock()
+ defer CacheMutex.Unlock()
+
+ instanceIDs, err := Cache.SMembers(ctx, InstancesSetKey).Result()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get instance IDs: %w", err)
+ }
+
+ now := time.Now()
+ var expired []*Instance
+
+ for _, id := range instanceIDs {
+ key := instanceKey(id)
+ data, err := Cache.Get(ctx, key).Bytes()
+ if err != nil {
+ Cache.SRem(ctx, InstancesSetKey, id)
+ continue
+ }
+
+ var instance Instance
+ err = json.Unmarshal(data, &instance)
+ if err != nil {
+ continue
+ }
+
+ if instance.ExpiresAt.Before(now) {
+ expired = append(expired, &instance)
+ }
+ }
+
+ return expired, nil
+}
diff --git a/core/cache/ports.go b/core/cache/ports.go
new file mode 100644
index 00000000..739053e1
--- /dev/null
+++ b/core/cache/ports.go
@@ -0,0 +1,120 @@
+package cache
+
+import (
+ "context"
+ "fmt"
+ "github.com/sdslabs/beastv4/utils"
+ "strconv"
+)
+
+func GetFreePort(host string, firstPort uint32, portRange uint32) (uint32, error) {
+ if Cache == nil {
+ Init()
+ }
+
+ CacheMutex.Lock()
+ defer CacheMutex.Unlock()
+
+ ctx := context.Background()
+ hostKey := utils.HostToKey(host)
+
+ for i := range portRange {
+ port := firstPort + i
+ result, err := Cache.SAdd(ctx, hostKey, port).Result()
+ if err != nil {
+ return 0, err
+ }
+
+ if result == 1 {
+ return port, nil
+ }
+ }
+
+ return 0, fmt.Errorf("no free port found on host: %s", host)
+}
+
+func RegisterFreePort(host string, containerId string, port uint32) error {
+ CacheMutex.Lock()
+ defer CacheMutex.Unlock()
+
+ ctx := context.Background()
+ instanceKey := utils.ContainerToKey(host, containerId)
+
+ result, err := Cache.SAdd(ctx, instanceKey, port).Result()
+ if err != nil {
+ return err
+ }
+
+ if result == 1 {
+ return nil
+ }
+
+ return fmt.Errorf("port: %v on host: %s is already registered to instance: %s", port, host, containerId)
+}
+
+func GetContainerPorts(host string, containerId string) ([]uint32, error) {
+ if Cache == nil {
+ Init()
+ }
+
+ CacheMutex.Lock()
+ defer CacheMutex.Unlock()
+
+ ctx := context.Background()
+ instanceKey := utils.ContainerToKey(host, containerId)
+
+ result, err := Cache.SMembers(ctx, instanceKey).Result()
+ if err != nil {
+ return nil, err
+ }
+
+ ports := make([]uint32, len(result))
+ for i, s := range result {
+ port, err := strconv.ParseUint(s, 10, 32)
+ if err != nil {
+ return nil, err
+ }
+
+ ports[i] = uint32(port)
+ }
+
+ return ports, nil
+}
+
+func FreeContainerPorts(host string, containerId string) error {
+ if Cache == nil {
+ Init()
+ }
+
+ CacheMutex.Lock()
+ defer CacheMutex.Unlock()
+
+ ctx := context.Background()
+ hostKey := utils.HostToKey(host)
+ instanceKey := utils.ContainerToKey(host, containerId)
+
+ result, err := Cache.SMembers(ctx, 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
+ }
+ }
+
+ return nil
+}
diff --git a/core/config/challenge.go b/core/config/challenge.go
index 1452d31a..c9dcbedf 100644
--- a/core/config/challenge.go
+++ b/core/config/challenge.go
@@ -137,15 +137,31 @@ type ChallengeMetadata struct {
Text string `toml:"text"`
Points uint `toml:"points"`
} `toml:"hints"`
- MaxAttemptLimit int `toml:"maxAttemptLimit"`
- PreReqs []string `toml:"preReqs"`
- DynamicFlag bool `toml:"dynamicFlag"`
- Points uint `toml:"points"`
- MaxPoints uint `toml:"maxPoints"`
- MinPoints uint `toml:"minPoints"`
- Assets []string `toml:"assets"`
- AdditionalLinks []string `toml:"additionalLinks"`
- Difficulty string `toml:"difficulty"`
+ MaxAttemptLimit int `toml:"maxAttemptLimit"`
+ PreReqs []string `toml:"preReqs"`
+ DynamicFlag bool `toml:"dynamicFlag"`
+ Points uint `toml:"points"`
+ MaxPoints uint `toml:"maxPoints"`
+ MinPoints uint `toml:"minPoints"`
+ Assets []string `toml:"assets"`
+ AdditionalLinks []string `toml:"additionalLinks"`
+ Difficulty string `toml:"difficulty"`
+ Instanced bool `toml:"instanced"`
+ InstanceExpiration int64 `toml:"instance_expiration"`
+}
+
+func (config *ChallengeMetadata) IsInstanced() bool {
+ return config.Instanced
+}
+
+func (config *ChallengeMetadata) GetInstanceExpiration() int64 {
+ if config.InstanceExpiration > 0 {
+ return config.InstanceExpiration
+ }
+ if Cfg != nil && Cfg.InstanceConfig.DefaultExpiration > 0 {
+ return Cfg.InstanceConfig.DefaultExpiration
+ }
+ return 300
}
// In this validation returned boolean value represents if the challenge type is
@@ -252,7 +268,6 @@ type ChallengeEnv struct {
AptDeps []string `toml:"apt_deps"`
Ports []uint32 `toml:"ports"`
DefaultPort uint32 `toml:"default_port"`
- PortMappings []string `toml:"port_mappings"`
SetupScripts []string `toml:"setup_scripts"`
StaticContentDir string `toml:"static_dir"`
RunCmd string `toml:"run_cmd"`
@@ -274,107 +289,15 @@ func (config *ChallengeEnv) TrafficType() cr.TrafficType {
return cr.TrafficType(config.Traffic)
}
-// NewPortMapping returns a new port mapping instance.
-func NewPortMapping(hp, cp uint32) cr.PortMapping {
- return cr.PortMapping{
- HostPort: hp,
- ContainerPort: cp,
- }
-}
-
-// Given a port mapping array and a port the function checks whether the port exists in the mapping
-// as a container port.
-func checkIfPortExistInMapping(portMapping []cr.PortMapping, port uint32) bool {
- for _, portMap := range portMapping {
- if port == portMap.ContainerPort {
- return true
- }
- }
-
- return false
-}
-
-// GetPortMappings returns the entire port mapping for the challenge from the challenge
-// environment configuration.
-func (config *ChallengeEnv) GetPortMappings() ([]cr.PortMapping, error) {
- var mapping []cr.PortMapping
-
- var containerPorts []uint32
- for _, portMap := range config.PortMappings {
- hp, cp, err := utils.ParsePortMapping(portMap)
- if err != nil {
- return mapping, err
- }
- mapping = append(mapping, NewPortMapping(hp, cp))
- containerPorts = append(containerPorts, cp)
- }
-
- for _, port := range config.Ports {
- if !utils.UInt32InList(port, containerPorts) {
- containerPorts = append(containerPorts, port)
- mapping = append(mapping, NewPortMapping(port, port))
- }
- }
-
- return mapping, nil
-}
-
-// GetAllHostPorts is utility function for the ChallengeEnv configuration which returns
-// the entire list of all the host ports which are being used by the challenge.
-func (config *ChallengeEnv) GetAllHostPorts() ([]uint32, error) {
- var hostPorts []uint32
- var containerPorts []uint32
-
- for _, portMap := range config.PortMappings {
- hp, cp, err := utils.ParsePortMapping(portMap)
- if err != nil {
- return hostPorts, err
- }
- hostPorts = append(hostPorts, hp)
- containerPorts = append(containerPorts, cp)
- }
-
- for _, port := range config.Ports {
- if !utils.UInt32InList(port, containerPorts) {
- hostPorts = append(hostPorts, port)
- containerPorts = append(containerPorts, port)
- }
- }
-
- return hostPorts, nil
-}
-
-// GetAllContainerPorts is utility function for the ChallengeEnv configuration which returns
-// the entire list of all the container ports which are being used by the challenge.
-func (config *ChallengeEnv) GetAllContainerPorts() ([]uint32, error) {
- var containerPorts []uint32
-
- for _, portMap := range config.PortMappings {
- _, cp, err := utils.ParsePortMapping(portMap)
- if err != nil {
- return containerPorts, err
- }
- containerPorts = append(containerPorts, cp)
- }
-
- for _, port := range config.Ports {
- if !utils.UInt32InList(port, containerPorts) {
- containerPorts = append(containerPorts, port)
- }
- }
-
- return containerPorts, nil
-}
-
// GetDefaultPort returns the default port used by the challenge from the challenge environment
// configuration.
func (config *ChallengeEnv) GetDefaultPort() uint32 {
- mappings, err := config.GetPortMappings()
- if err != nil || len(mappings) == 0 {
+ ports := config.Ports
+ if len(ports) == 0 {
return 0
}
- return mappings[0].ContainerPort
+ return ports[0]
}
// ValidateRequiredFields validates required fields for the Challenge environment configuration.
@@ -382,35 +305,14 @@ func (config *ChallengeEnv) GetDefaultPort() uint32 {
// of the challenge.
func (config *ChallengeEnv) ValidateRequiredFields(challType string, challdir string) error {
// Validate port related stuff for the challenge environment configuration.
- if len(config.Ports) == 0 && len(config.PortMappings) == 0 {
+ if len(config.Ports) == 0 && config.DefaultPort == 0 {
return errors.New("some port is required to be specified by the challenge")
}
- if len(config.Ports)+len(config.PortMappings) > int(core.MAX_PORT_PER_CHALL) {
+ 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))
}
- portMappings, err := config.GetPortMappings()
- if err != nil {
- return fmt.Errorf("error while parsing port mapping: %s", err)
- }
-
- // By default if no port is specified to be default, the first port
- // from the list is assumed to be default and the service is deployed accordingly.
- if config.DefaultPort == 0 {
- config.DefaultPort = portMappings[0].ContainerPort
- }
-
- if !checkIfPortExistInMapping(portMappings, config.DefaultPort) {
- return fmt.Errorf("`default_port` must be one of the Ports in the `ports` list")
- }
-
- for _, portMap := range portMappings {
- if portMap.HostPort < core.ALLOWED_MIN_PORT_VALUE || portMap.HostPort > core.ALLOWED_MAX_PORT_VALUE {
- return fmt.Errorf("port value must be between %d and %d", core.ALLOWED_MIN_PORT_VALUE, core.ALLOWED_MAX_PORT_VALUE)
- }
- }
-
if config.StaticContentDir != "" {
if filepath.IsAbs(config.StaticContentDir) {
return fmt.Errorf("static content directory path should be relative to challenge directory root")
diff --git a/core/config/config.go b/core/config/config.go
index 167458f2..d41a96cb 100644
--- a/core/config/config.go
+++ b/core/config/config.go
@@ -108,7 +108,7 @@ import (
// dbname = "beast"
// host = "localhost"
// port = "5432"
-// sslmode = "prefer"
+// sslmode = "prefer"
// ```
type BeastConfig struct {
AuthorizedKeysFile string `toml:"authorized_keys_file"`
@@ -117,6 +117,7 @@ type BeastConfig struct {
AvailableServers map[string]AvailableServer `toml:"available_servers"`
GitRemotes []GitRemote `toml:"remote"`
PsqlConf PsqlConfig `toml:"psql_config"`
+ RedisConf RedisConfig `toml:"redis_config"`
JWTSecret string `toml:"jwt_secret"`
NotificationWebhooks []NotificationWebhook `toml:"notification_webhooks"`
CompetitionInfo CompetitionInfo `toml:"competition_info"`
@@ -125,15 +126,59 @@ type BeastConfig struct {
HealthProber bool `toml:"health_prober"`
RemoteSyncPeriod time.Duration `toml:"-"`
Rsp string `toml:"remote_sync_period"`
+ LocalHostPortRange string `toml:"local_host_port_range"`
+ InstanceConfig InstanceConfig `toml:"instance_config"`
CPUShares int64 `toml:"default_cpu_shares"`
Memory int64 `toml:"default_memory_limit"`
PidsLimit int64 `toml:"default_pids_limit"`
- // For SMTP Configuration
MailConfig MailConfig `toml:"mail_config"`
}
+type InstanceConfig struct {
+ DefaultExpiration int64 `toml:"default_expiration"`
+ MaxExtension int64 `toml:"max_extension"`
+ MaxInstancesPerUser int `toml:"max_instances_per_user"`
+}
+
+func (config *InstanceConfig) Validate() {
+ if config.DefaultExpiration <= 0 {
+ config.DefaultExpiration = 300
+ }
+ if config.MaxExtension <= 0 {
+ config.MaxExtension = 600
+ }
+ if config.MaxInstancesPerUser <= 0 {
+ config.MaxInstancesPerUser = 3
+ }
+}
+
+func ValidatePortRange(portRange string) error {
+ if portRange == "" {
+ return nil
+ }
+
+ firstPort, lastPort, err := utils.ParsePortMapping(portRange)
+ if err != nil {
+ return fmt.Errorf("error while parsing port range in global beast config: %s", err)
+ }
+
+ if firstPort > lastPort {
+ return fmt.Errorf("invalid port range, %v cannot be greater than %v", firstPort, lastPort)
+ }
+
+ if firstPort < core.ALLOWED_MIN_PORT_VALUE {
+ return fmt.Errorf("invalid port range, range cannot precede %v", core.ALLOWED_MIN_PORT_VALUE)
+ }
+
+ if lastPort > core.ALLOWED_MAX_PORT_VALUE {
+ return fmt.Errorf("invalid port range, range cannot exceed %v", core.ALLOWED_MAX_PORT_VALUE)
+ }
+
+ return nil
+}
+
func (config *BeastConfig) ValidateConfig() error {
log.Debug("Validating BeastConfig structure")
@@ -170,6 +215,11 @@ func (config *BeastConfig) ValidateConfig() error {
return fmt.Errorf("error while validating db config : %s", err)
}
+ err = config.RedisConf.ValidateRedisConfig()
+ if err != nil {
+ return fmt.Errorf("error while validating redis config : %s", err)
+ }
+
if len(config.AvailableServers) == 0 {
log.Warn("No available servers provided for challenges. Using default localhost")
config.AvailableServers = map[string]AvailableServer{
@@ -233,6 +283,11 @@ func (config *BeastConfig) ValidateConfig() error {
}
}
+ err = ValidatePortRange(config.LocalHostPortRange)
+ if err != nil {
+ return fmt.Errorf("error while validating port range in global beast config: %s", err)
+ }
+
if config.CPUShares <= 0 {
log.Debug("Per container CPU shares not provided using default value")
config.CPUShares = core.DEFAULT_CPU_SHARE
@@ -252,6 +307,8 @@ func (config *BeastConfig) ValidateConfig() error {
log.Warn("Mail configuration not provided, email notifications will not work")
}
+ config.InstanceConfig.Validate()
+
return nil
}
@@ -260,6 +317,7 @@ type AvailableServer struct {
Username string `toml:"username"`
SSHKeyPath string `toml:"ssh_key_path"`
Active bool `toml:"active"`
+ PortRange string `toml:"port_range"`
}
func (config *AvailableServer) ValidateServerConfig() error {
@@ -274,6 +332,12 @@ func (config *AvailableServer) ValidateServerConfig() error {
if err != nil {
return fmt.Errorf("provided ssh key file(%s) does not exists : %s", config.SSHKeyPath, err)
}
+
+ err = ValidatePortRange(config.PortRange)
+ if err != nil {
+ return fmt.Errorf("error while validating port range for server %s: %s", config.Host, err)
+ }
+
return nil
}
@@ -324,6 +388,14 @@ type PsqlConfig struct {
SslMode string `toml:"sslmode"`
}
+type RedisConfig struct {
+ User string `toml:"user"`
+ Password string `toml:"password"`
+ Host string `toml:"host"`
+ Port string `toml:"port"`
+ Db uint32 `toml:"db"`
+}
+
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")
@@ -336,6 +408,14 @@ func (config *PsqlConfig) ValidatePsqlConfig() error {
return nil
}
+func (config *RedisConfig) ValidateRedisConfig() error {
+ if config.Host == "" || config.Port == "" {
+ log.Error("One of hostname or port is missing in the config")
+ return errors.New("redis config not valid, config parameters missing")
+ }
+ return nil
+}
+
type NotificationWebhook struct {
URL string `toml:"url"`
ServiceName string `toml:"service_name"`
@@ -438,44 +518,9 @@ func LoadBeastConfig(configPath string) (BeastConfig, error) {
return config, nil
}
-// Update the USED_PORT_LIST variable in config.
-// Don't do this very often, we do this once during syncing the git repository
-// then whenever you need updated used port list you need to sync the git remote
-// by beast.
-func UpdateUsedPortList() {
- USED_PORTS_LIST = make([]uint32, 0)
-
- beastRemoteDir := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_REMOTES_DIR)
-
- for _, gitRemote := range Cfg.GitRemotes {
- if !gitRemote.Active {
- continue
- }
-
- challengeDir := filepath.Join(beastRemoteDir, gitRemote.RemoteName, core.BEAST_REMOTE_CHALLENGE_DIR)
- dirs := utils.GetAllDirectoriesName(challengeDir)
- for _, dir := range dirs {
- configFilePath := filepath.Join(dir, core.CHALLENGE_CONFIG_FILE_NAME)
- var config BeastChallengeConfig
- _, err := toml.DecodeFile(configFilePath, &config)
- if err == nil {
- hostPorts, err := config.Challenge.Env.GetAllHostPorts()
- if err != nil {
- log.Errorf("Error while parsing host ports for challenge %s", dir)
- continue
- }
-
- USED_PORTS_LIST = append(USED_PORTS_LIST, hostPorts...)
- }
- }
- }
- log.Debugf("Used port list updated: %v", USED_PORTS_LIST)
-}
-
var Cfg *BeastConfig
var SkipAuthorization bool
var NoCache bool
-var USED_PORTS_LIST []uint32
// InitConfig loads the config from the global config file and populate
// the Cfg global variable used everywhere else.
diff --git a/core/constants.go b/core/constants.go
index 75919614..e2480e78 100644
--- a/core/constants.go
+++ b/core/constants.go
@@ -40,6 +40,8 @@ const ( //names
BEAST_GRAPH_CACHE string = "graph_cache.json"
BEAST_LEADERBOARD_CACHE string = "leaderboard.json"
POSTGRES_SUPER_USER string = "postgres"
+ REDIS_DEFAULT_USER string = "default"
+ SAD_CHECK_SCRIPT string = "check.sh"
)
const ( //paths
@@ -57,6 +59,7 @@ const ( //paths
BEAST_SECRETS_DIR string = "secrets"
BEAST_EXAMPLE_DIR string = "_examples"
BEAST_CACHE_DIR string = "cache"
+ SAD_CHECK_SCRIPT_LOCATION string = BEAST_DOCKER_CHALLENGE_DIR + "/" + SAD_CHECK_SCRIPT
)
const ( //chall types
@@ -97,7 +100,7 @@ const ( // default config
ITERATIONS int = 65536
HASH_LENGTH int = 32
TIMEPERIOD int64 = 6 * 60 * 60
- SSH_PORT int = 22
+ SSH_PORT uint32 = 22
)
const ( // roles
diff --git a/core/database/challenges.go b/core/database/challenges.go
index fb8a5623..33b14b3a 100644
--- a/core/database/challenges.go
+++ b/core/database/challenges.go
@@ -46,30 +46,32 @@ import (
type Challenge struct {
gorm.Model
- Name string `gorm:"not null;type:varchar(64);unique"`
- DynamicFlag bool `gorm:"not null;default:false"`
- Flag string `gorm:"type:text"`
- Type string `gorm:"type:varchar(64)"`
- Difficulty string `gorm:"not null;default:'medium'"`
- MaxAttemptLimit int `gorm:"default:-1"`
- PreReqs string `gorm:"type:text"`
- Assets string `gorm:"type:text"`
- 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"`
- Status string `gorm:"not null;default:'Undeployed'"`
- DeploymentType string `gorm:"not null;default:'standard_docker'"`
- AuthorID uint `gorm:"not null"`
- HealthCheck uint `gorm:"not null;default:1"`
- Points uint `gorm:"default:0"`
- MaxPoints uint `gorm:"default:0"`
- MinPoints uint `gorm:"default:0"`
- Ports []Port
- Tags []*Tag `gorm:"many2many:tag_challenges;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"`
- Users []*User `gorm:"many2many:user_challenges;"`
- ServerDeployed string `gorm:"type:varchar(64)"`
+ Name string `gorm:"not null;type:varchar(64);unique"`
+ DynamicFlag bool `gorm:"not null;default:false"`
+ Flag string `gorm:"type:text"`
+ Type string `gorm:"type:varchar(64)"`
+ Difficulty string `gorm:"not null;default:'medium'"`
+ MaxAttemptLimit int `gorm:"default:-1"`
+ PreReqs string `gorm:"type:text"`
+ Assets string `gorm:"type:text"`
+ 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"`
+ Status string `gorm:"not null;default:'Undeployed'"`
+ DeploymentType string `gorm:"not null;default:'standard_docker'"`
+ AuthorID uint `gorm:"not null"`
+ HealthCheck uint `gorm:"not null;default:1"`
+ Points uint `gorm:"default:0"`
+ MaxPoints uint `gorm:"default:0"`
+ MinPoints uint `gorm:"default:0"`
+ Ports []Port
+ Tags []*Tag `gorm:"many2many:tag_challenges;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"`
+ Users []*User `gorm:"many2many:user_challenges;"`
+ ServerDeployed string `gorm:"type:varchar(64)"`
+ Instanced bool `gorm:"not null;default:false"`
+ InstanceExpiration int64 `gorm:"default:0"`
}
type UserChallenges struct {
@@ -171,7 +173,7 @@ func QueryAllChallengesMetadata() ([]Challenge, error) {
DBMux.Lock()
defer DBMux.Unlock()
- tx := Db.Select("id", "name", "created_at", "points", "difficulty").
+ tx := Db.Select("id", "name", "created_at", "points", "difficulty", "instanced", "instance_expiration", "status").
Preload("Tags").
Find(&challenges)
@@ -204,7 +206,7 @@ func QueryChallengeEntries(key string, value string) ([]Challenge, error) {
return challenges, nil
}
-// QueryChallengeEntriesMetadata returns only selected columns: Name, ID, Tags, CreatedAt, Points, Difficulty
+// 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)
@@ -214,7 +216,7 @@ func QueryChallengeEntriesMetadata(key string, value string) ([]Challenge, error
defer DBMux.Unlock()
// Only select the required columns, but preload Tags for tag names
- tx := Db.Select("id", "name", "created_at", "points", "difficulty").
+ tx := Db.Select("id", "name", "created_at", "points", "difficulty", "instanced", "instance_expiration", "status").
Preload("Tags").
Where(queryKey, value).
Find(&challenges)
@@ -489,7 +491,7 @@ func QuerySubmissions(whereMap map[string]interface{}) ([]UserChallenges, error)
return userChallenges, nil
}
-func SaveFlagSubmission(user_challenges *UserChallenges) error {
+func SaveChallengeSubmission(user_challenges *UserChallenges) error {
DBMux.Lock()
defer DBMux.Unlock()
diff --git a/core/database/tag.go b/core/database/tag.go
index 0408a1c3..376e9061 100644
--- a/core/database/tag.go
+++ b/core/database/tag.go
@@ -61,7 +61,7 @@ func QueryRelatedChallengesMetadata(tag *Tag) ([]Challenge, error) {
Db.Where(&Tag{TagName: tag.TagName}).First(&tagName)
if err := Db.Model(&tagName).
- Select("id", "name", "created_at", "points", "difficulty").
+ Select("id", "name", "created_at", "points", "difficulty", "instanced", "instance_expiration", "status").
Preload("Tags").
Association("Challenges").
Find(&challenges); err != nil {
diff --git a/core/manager/challenge.go b/core/manager/challenge.go
index ec14be45..4ff2ce79 100644
--- a/core/manager/challenge.go
+++ b/core/manager/challenge.go
@@ -3,6 +3,7 @@ package manager
import (
"errors"
"fmt"
+ "github.com/sdslabs/beastv4/core/cache"
"path/filepath"
"strings"
@@ -640,52 +641,73 @@ func undeployChallenge(challengeName string, purge bool) error {
return fmt.Errorf("ChallengeName %s not valid", challengeName)
}
- if challenge.DeploymentType == core.DEPLOYMENT_TYPES["docker_compose"] {
- log.Debugf("Detected Docker Compose deployment for challenge %s", challengeName)
+ /* TODO: verify this cleanup */
+ if challenge.Instanced {
+ // Kill all active instances of this challenge before undeploying
+ if err := KillChallengeInstances(challengeName); err != nil {
+ log.Warnf("Error killing instances for challenge %s: %v", challengeName, err)
+ // Continue with undeploy even if some instances failed to kill
+ }
+ } else {
+ if challenge.DeploymentType == core.DEPLOYMENT_TYPES["docker_compose"] {
+ log.Debugf("Detected Docker Compose deployment for challenge %s", challengeName)
- stagedDir := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_STAGING_DIR, challengeName)
- if challenge.ServerDeployed != core.LOCALHOST && challenge.ServerDeployed != "" {
- server := config.Cfg.AvailableServers[challenge.ServerDeployed]
+ stagedDir := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_STAGING_DIR, challengeName)
+ if challenge.ServerDeployed != core.LOCALHOST && challenge.ServerDeployed != "" {
+ server := config.Cfg.AvailableServers[challenge.ServerDeployed]
- if !purge {
- err = remoteManager.ComposeDownRemote(challengeName, stagedDir, server)
+ if !purge {
+ err = remoteManager.ComposeDownRemote(challengeName, stagedDir, server)
+ } else {
+ err = remoteManager.ComposePurgeRemote(challengeName, stagedDir, server)
+ }
} else {
- err = remoteManager.ComposePurgeRemote(challengeName, stagedDir, server)
+ if !purge {
+ err = cr.ComposeDown(challengeName, stagedDir)
+ } else {
+ err = cr.ComposePurge(challengeName, stagedDir)
+ }
+ }
+ if err != nil {
+ log.Errorf("Error while removing challenge instance : %s", err)
+ return fmt.Errorf("error while removing challenge instance : %s", err)
}
} else {
- if !purge {
- err = cr.ComposeDown(challengeName, stagedDir)
+ // If a existing container ID is not found make sure that you atleast
+ // set the deploy status to undeployed. This earlier caused problem since if a challenge
+ // was in staging state(and deployed is cancled) then we can neither deploy new
+ // version nor we can undeploy the existing version(since it does not exist)
+ // So this....
+ if challenge.ContainerId == coreUtils.GetTempContainerId(challengeName) {
+ log.Warnf("No instance of challenge(%s) deployed", challengeName)
} else {
- err = cr.ComposePurge(challengeName, stagedDir)
+ log.Debug("Removing challenge instance for ", challengeName)
+ if challenge.ServerDeployed != core.LOCALHOST && challenge.ServerDeployed != "" {
+ server := config.Cfg.AvailableServers[challenge.ServerDeployed]
+ err = remoteManager.StopAndRemoveContainerRemote(challenge.ContainerId, server)
+ } else {
+ err = cr.StopAndRemoveContainer(challenge.ContainerId)
+ }
+ if err != nil {
+ // This should not return from here, this should assume that
+ // the container instance does not exist and hence should update the database
+ // with the container ID.
+ p := fmt.Errorf("error while removing challenge instance : %s", err)
+ log.Error(p.Error())
+ }
}
}
- if err != nil {
- log.Errorf("Error while removing challenge instance : %s", err)
- return fmt.Errorf("error while removing challenge instance : %s", err)
- }
- } else {
- // If a existing container ID is not found make sure that you atleast
- // set the deploy status to undeployed. This earlier caused problem since if a challenge
- // was in staging state(and deployed is cancled) then we can neither deploy new
- // version nor we can undeploy the existing version(since it does not exist)
- // So this....
- if challenge.ContainerId == coreUtils.GetTempContainerId(challengeName) {
- log.Warnf("No instance of challenge(%s) deployed", challengeName)
+
+ var host string
+ if challenge.ServerDeployed == core.LOCALHOST || challenge.ServerDeployed == "" {
+ host = core.LOCALHOST
} else {
- log.Debug("Removing challenge instance for ", challengeName)
- if challenge.ServerDeployed != core.LOCALHOST && challenge.ServerDeployed != "" {
- server := config.Cfg.AvailableServers[challenge.ServerDeployed]
- err = remoteManager.StopAndRemoveContainerRemote(challenge.ContainerId, server)
- } else {
- err = cr.StopAndRemoveContainer(challenge.ContainerId)
- }
- if err != nil {
- // This should not return from here, this should assume that
- // the container instance does not exist and hence should update the database
- // with the container ID.
- p := fmt.Errorf("error while removing challenge instance : %s", err)
- log.Error(p.Error())
- }
+ host = config.Cfg.AvailableServers[challenge.ServerDeployed].Host
+ }
+
+ err = cache.FreeContainerPorts(host, challenge.ContainerId)
+ if err != nil {
+ return fmt.Errorf("error while freeing ports for container %s on host %s: %s", challenge.ContainerId, host, err)
}
}
diff --git a/core/manager/health_check.go b/core/manager/health_check.go
index 767dcd10..4039f40b 100644
--- a/core/manager/health_check.go
+++ b/core/manager/health_check.go
@@ -1,12 +1,17 @@
package manager
import (
+ "bytes"
+ "encoding/json"
"fmt"
+ "os/exec"
"path/filepath"
"strings"
"time"
+ "github.com/docker/docker/api/types"
"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/cr"
@@ -145,19 +150,325 @@ func ServerHealthProber(waitTime int) {
}
}
-// Check for beast services running or not
func BeastHeathCheckProber(waitTime int) {
if !HEALTH_CHECKER {
log.Info("Starting Health Check prober.")
HEALTH_CHECKER = true
+
+ go InstanceCleanupProber()
+
for {
go ChallengesHealthProber(waitTime)
go ServerHealthProber(waitTime)
go database.BackupDatabase()
- // Wait for some time before next probing.
+ go cache.BackupCache()
time.Sleep(time.Duration(waitTime) * time.Second)
}
} else {
log.Warn("Health Checker Already Running. Not Starting Again")
}
}
+
+func InstanceCleanupProber() {
+ cleanupInterval := 30 * time.Second
+ log.Info("Starting Instance Cleanup prober with interval: ", cleanupInterval)
+
+ for {
+ QueueExpiredInstances()
+ ProcessInstanceDeletionQueue()
+ CleanupOrphanedInstanceContainers()
+ time.Sleep(cleanupInterval)
+ }
+}
+
+func QueueExpiredInstances() {
+ log.Debug("Checking for expired instances")
+
+ expired, err := cache.GetExpiredInstances()
+ if err != nil {
+ log.Warnf("Failed to get expired instances: %v", err)
+ return
+ }
+
+ for _, instance := range expired {
+ log.Infof("Instance %s expired (challenge: %s, user: %s), queueing for deletion",
+ instance.InstanceID, instance.ChallengeName, instance.UserID)
+
+ err := cache.QueueInstanceForDeletion(instance.InstanceID)
+ if err != nil {
+ log.Warnf("Failed to queue instance %s for deletion: %v", instance.InstanceID, err)
+ }
+ }
+}
+
+func ProcessInstanceDeletionQueue() {
+ log.Debug("Processing instance deletion queue")
+
+ for i := 0; i < 10; i++ {
+ instance, err := cache.PopInstanceForDeletion()
+ if err != nil {
+ log.Warnf("Error popping from deletion queue: %v", err)
+ return
+ }
+
+ if instance == nil {
+ return
+ }
+
+ log.Infof("Processing deletion for instance %s (challenge: %s, container: %s, server: %s)",
+ instance.InstanceID, instance.ChallengeName, instance.ContainerID, instance.ServerDeployed)
+
+ err = killInstanceContainer(instance.ContainerID, instance.DeploymentType, instance.InstanceID, instance.ChallengeName, instance.ServerDeployed)
+ if err != nil {
+ log.Warnf("Failed to kill container for instance %s: %v", instance.InstanceID, err)
+ } else {
+ log.Infof("Successfully killed container for instance %s", instance.InstanceID)
+ }
+
+ cache.FreeContainerPorts(instance.ServerDeployed, instance.ContainerID)
+ }
+
+ queueLen, _ := cache.GetDeletionQueueLength()
+ if queueLen > 0 {
+ log.Debugf("Deletion queue still has %d items, will process in next cycle", queueLen)
+ }
+}
+
+func CleanupOrphanedInstanceContainers() {
+ log.Debug("Checking for orphaned instance containers")
+
+ cleanupOrphanedOnServer(core.LOCALHOST)
+ cleanupOrphanedComposeInstancesOnServer(core.LOCALHOST)
+
+ for host, server := range config.Cfg.AvailableServers {
+ if server.Active && host != core.LOCALHOST {
+ cleanupOrphanedOnServer(host)
+ cleanupOrphanedComposeInstancesOnServer(host)
+ }
+ }
+}
+
+func cleanupOrphanedOnServer(serverHost string) {
+ var containers []types.Container
+ var err error
+
+ if serverHost == core.LOCALHOST {
+ containers, err = cr.SearchContainerByFilter(map[string]string{
+ "label": "beast.instance=true",
+ })
+ } else {
+ server := config.Cfg.AvailableServers[serverHost]
+ containers, err = remoteManager.SearchContainerByFilterRemote(map[string]string{
+ "label": "beast.instance=true",
+ }, server)
+ }
+
+ if err != nil {
+ log.Warnf("Failed to search for instance containers on %s: %v", serverHost, err)
+ return
+ }
+
+ for _, container := range containers {
+ instanceID := container.Labels["beast.instance.id"]
+ if instanceID == "" {
+ for _, name := range container.Names {
+ name = strings.TrimPrefix(name, "/")
+ if strings.HasPrefix(name, "beast_instance_") {
+ parts := strings.Split(name, "_")
+ if len(parts) >= 4 {
+ instanceID = parts[len(parts)-1]
+ break
+ }
+ }
+ }
+ }
+
+ if instanceID == "" {
+ continue
+ }
+
+ _, err := cache.GetInstance(instanceID)
+ if err != nil {
+ containerName := ""
+ if len(container.Names) > 0 {
+ containerName = strings.TrimPrefix(container.Names[0], "/")
+ }
+ log.Infof("Removing orphaned instance container: %s (instance %s) on %s", containerName, instanceID, serverHost)
+
+ if serverHost == core.LOCALHOST {
+ if err := cr.StopAndRemoveContainer(container.ID); err != nil {
+ log.Warnf("Failed to remove orphaned container %s: %v", container.ID[:12], err)
+ }
+ } else {
+ server := config.Cfg.AvailableServers[serverHost]
+ if err := remoteManager.StopAndRemoveContainerRemote(container.ID, server); err != nil {
+ log.Warnf("Failed to remove orphaned container %s on %s: %v", container.ID[:12], serverHost, err)
+ }
+ }
+
+ cache.FreeContainerPorts(serverHost, container.ID)
+ }
+ }
+}
+
+// cleanupOrphanedComposeInstancesOnServer finds and removes orphaned docker compose instance projects.
+// Docker Compose containers don't have the beast.instance labels, but they have
+// com.docker.compose.project labels with project names starting with "beast-instance-".
+func cleanupOrphanedComposeInstancesOnServer(serverHost string) {
+ var projectNames []string
+ var err error
+
+ if serverHost == core.LOCALHOST {
+ projectNames, err = getOrphanedComposeInstanceProjects()
+ } else {
+ server := config.Cfg.AvailableServers[serverHost]
+ projectNames, err = getOrphanedComposeInstanceProjectsRemote(server)
+ }
+
+ if err != nil {
+ log.Warnf("Failed to get compose instance projects on %s: %v", serverHost, err)
+ return
+ }
+
+ for _, projectName := range projectNames {
+ // Extract instance ID from project name: beast-instance-{encoded_challenge}-{instanceID}
+ parts := strings.Split(projectName, "-")
+ if len(parts) < 4 {
+ continue
+ }
+ instanceID := parts[len(parts)-1]
+
+ // Check if instance still exists in cache
+ _, err := cache.GetInstance(instanceID)
+ if err != nil {
+ log.Infof("Removing orphaned compose instance project: %s (instance %s) on %s", projectName, instanceID, serverHost)
+
+ if serverHost == core.LOCALHOST {
+ if err := composeDownProject(projectName); err != nil {
+ log.Warnf("Failed to remove orphaned compose project %s: %v", projectName, err)
+ }
+ } else {
+ server := config.Cfg.AvailableServers[serverHost]
+ if err := composeDownProjectRemote(projectName, server); err != nil {
+ log.Warnf("Failed to remove orphaned compose project %s on %s: %v", projectName, serverHost, err)
+ }
+ }
+ }
+ }
+}
+
+// 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())
+ }
+
+ type ComposeProject struct {
+ Name string `json:"Name"`
+ Status string `json:"Status"`
+ }
+
+ var projects []ComposeProject
+ outputStr := strings.TrimSpace(output.String())
+ if outputStr == "" {
+ return nil, nil
+ }
+
+ if err := json.Unmarshal([]byte(outputStr), &projects); err != nil {
+ // Try parsing line by line (older docker compose versions)
+ for _, line := range strings.Split(outputStr, "\n") {
+ if strings.TrimSpace(line) == "" {
+ continue
+ }
+ var project ComposeProject
+ if err := json.Unmarshal([]byte(line), &project); err != nil {
+ continue
+ }
+ projects = append(projects, project)
+ }
+ }
+
+ var instanceProjects []string
+ for _, project := range projects {
+ if strings.HasPrefix(project.Name, "beast-instance-") {
+ instanceProjects = append(instanceProjects, project.Name)
+ }
+ }
+
+ return instanceProjects, nil
+}
+
+// getOrphanedComposeInstanceProjectsRemote returns compose instance projects on a remote server
+func getOrphanedComposeInstanceProjectsRemote(server config.AvailableServer) ([]string, error) {
+ output, err := remoteManager.RunCommandOnServer(server, "docker compose ls --format json")
+ if err != nil {
+ return nil, fmt.Errorf("docker compose ls failed on remote: %v", err)
+ }
+
+ type ComposeProject struct {
+ Name string `json:"Name"`
+ Status string `json:"Status"`
+ }
+
+ var projects []ComposeProject
+ outputStr := strings.TrimSpace(output)
+ if outputStr == "" {
+ return nil, nil
+ }
+
+ if err := json.Unmarshal([]byte(outputStr), &projects); err != nil {
+ // Try parsing line by line
+ for _, line := range strings.Split(outputStr, "\n") {
+ if strings.TrimSpace(line) == "" {
+ continue
+ }
+ var project ComposeProject
+ if err := json.Unmarshal([]byte(line), &project); err != nil {
+ continue
+ }
+ projects = append(projects, project)
+ }
+ }
+
+ var instanceProjects []string
+ for _, project := range projects {
+ if strings.HasPrefix(project.Name, "beast-instance-") {
+ instanceProjects = append(instanceProjects, project.Name)
+ }
+ }
+
+ 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())
+ }
+
+ log.Debugf("Successfully removed compose project %s", projectName)
+ return nil
+}
+
+// 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 := remoteManager.RunCommandOnServer(server, cmd)
+ if err != nil {
+ return fmt.Errorf("docker compose down failed on remote: %v, output: %s", err, output)
+ }
+
+ log.Debugf("Successfully removed compose project %s on %s", projectName, server.Host)
+ return nil
+}
diff --git a/core/manager/instance.go b/core/manager/instance.go
new file mode 100644
index 00000000..a706819e
--- /dev/null
+++ b/core/manager/instance.go
@@ -0,0 +1,568 @@
+package manager
+
+import (
+ "bytes"
+ "fmt"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/BurntSushi/toml"
+ "github.com/google/uuid"
+ "github.com/sdslabs/beastv4/core"
+ "github.com/sdslabs/beastv4/core/cache"
+ cfg "github.com/sdslabs/beastv4/core/config"
+ "github.com/sdslabs/beastv4/core/database"
+ coreUtils "github.com/sdslabs/beastv4/core/utils"
+ "github.com/sdslabs/beastv4/pkg/cr"
+ "github.com/sdslabs/beastv4/pkg/remoteManager"
+ "github.com/sdslabs/beastv4/utils"
+
+ log "github.com/sirupsen/logrus"
+)
+
+func SpawnInstance(challengeName, userID, username string) (*cache.Instance, error) {
+ log.Infof("Spawning instance of challenge %s for user %s", challengeName, userID)
+
+ existingInstance, err := cache.GetUserInstance(userID, challengeName)
+ if err == nil && existingInstance != nil {
+ return existingInstance, fmt.Errorf("user already has an active instance of this challenge")
+ }
+
+ instanceCount, err := cache.CountUserInstances(userID)
+ if err != nil {
+ log.Warnf("Failed to count user instances: %v", err)
+ } else if instanceCount >= cfg.Cfg.InstanceConfig.MaxInstancesPerUser {
+ return nil, fmt.Errorf("maximum instances limit reached (%d)", cfg.Cfg.InstanceConfig.MaxInstancesPerUser)
+ }
+
+ challenge, err := database.QueryFirstChallengeEntry("name", challengeName)
+ if err != nil {
+ return nil, fmt.Errorf("failed to query challenge: %w", err)
+ }
+ if challenge.ID == 0 {
+ return nil, fmt.Errorf("challenge not found: %s", challengeName)
+ }
+
+ stagingDir := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_STAGING_DIR, challengeName)
+ configFile := filepath.Join(stagingDir, core.CHALLENGE_CONFIG_FILE_NAME)
+
+ var config cfg.BeastChallengeConfig
+ _, err = toml.DecodeFile(configFile, &config)
+ if err != nil {
+ return nil, fmt.Errorf("failed to load challenge config: %w", err)
+ }
+
+ if !config.Challenge.Metadata.IsInstanced() {
+ return nil, fmt.Errorf("challenge %s is not configured for instancing", challengeName)
+ }
+
+ if challenge.ImageId == "" && config.Challenge.Env.DockerCompose == "" {
+ return nil, fmt.Errorf("challenge %s has not been committed (no image available)", challengeName)
+ }
+
+ serverDeployed := selectServerForInstance()
+
+ port, err := allocateInstancePort(serverDeployed)
+ if err != nil {
+ return nil, fmt.Errorf("failed to allocate port: %w", err)
+ }
+
+ instanceID := uuid.New().String()[:12]
+
+ expirationSeconds := config.Challenge.Metadata.GetInstanceExpiration()
+ ttl := time.Duration(expirationSeconds) * time.Second
+ expiresAt := time.Now().Add(ttl)
+
+ var checkHash string
+ var containerID string
+ var deploymentType string
+
+ if config.Challenge.Env.DockerCompose != "" {
+ containerID, checkHash, err = deployInstanceFromCompose(instanceID, challengeName, port, &config, stagingDir, serverDeployed)
+ deploymentType = core.DEPLOYMENT_TYPES["docker_compose"]
+ } else {
+ containerID, checkHash, err = deployInstanceContainer(instanceID, challengeName, port, challenge.ImageId, &config, serverDeployed)
+ deploymentType = core.DEPLOYMENT_TYPES["standard_docker"]
+ }
+
+ if err != nil {
+ if err := cache.FreeContainerPorts(serverDeployed, containerID); err != nil {
+ return nil, fmt.Errorf("failed to free container ports: %w", err)
+ }
+ return nil, fmt.Errorf("failed to deploy instance container: %w", err)
+ }
+
+ err = cache.RegisterFreePort(serverDeployed, containerID, port)
+ if err != nil {
+ log.Warnf("Failed to register port %d for container %s: %v", port, containerID, err)
+ }
+
+ instance := &cache.Instance{
+ InstanceID: instanceID,
+ ChallengeName: challengeName,
+ ContainerID: containerID,
+ HostedAddress: getHostedAddress(serverDeployed),
+ CheckHash: checkHash,
+ Port: port,
+ UserID: userID,
+ Username: username,
+ CreatedAt: time.Now(),
+ ExpiresAt: expiresAt,
+ DeploymentType: deploymentType,
+ ServerDeployed: serverDeployed,
+ }
+
+ err = cache.SaveInstance(instance, ttl)
+ if err != nil {
+ if err := killInstanceContainer(containerID, deploymentType, instanceID, challengeName, serverDeployed); err != nil {
+ return nil, fmt.Errorf("failed to kill instance container: %w", err)
+ }
+ if err := cache.FreeContainerPorts(serverDeployed, containerID); err != nil {
+ return nil, fmt.Errorf("failed to free container ports: %w", err)
+ }
+
+ return nil, fmt.Errorf("failed to save instance: %w", err)
+ }
+
+ log.Infof("Successfully spawned instance %s for user %s, challenge %s on port %d (server: %s)",
+ instanceID, userID, challengeName, port, serverDeployed)
+
+ return instance, nil
+}
+
+func KillInstance(instanceID string) error {
+ log.Infof("Killing instance %s", instanceID)
+
+ instance, err := cache.GetInstance(instanceID)
+ if err != nil {
+ return fmt.Errorf("instance not found: %w", err)
+ }
+
+ err = killInstanceContainer(instance.ContainerID, instance.DeploymentType, instanceID, instance.ChallengeName, instance.ServerDeployed)
+ if err != nil {
+ log.Warnf("Error killing container for instance %s: %v", instanceID, err)
+ }
+
+ err = cache.FreeContainerPorts(instance.ServerDeployed, instance.ContainerID)
+ if err != nil {
+ return fmt.Errorf("failed to free container ports: %w", err)
+ }
+
+ err = cache.DeleteInstance(instanceID)
+ if err != nil {
+ return fmt.Errorf("failed to delete instance from cache: %w", err)
+ }
+
+ log.Infof("Successfully killed instance %s", instanceID)
+ return nil
+}
+
+// KillUserInstance kills a user's instance of a specific challenge
+func KillUserInstance(userID, challengeName string) error {
+ instance, err := cache.GetUserInstance(userID, challengeName)
+ if err != nil {
+ return fmt.Errorf("instance not found: %w", err)
+ }
+
+ return KillInstance(instance.InstanceID)
+}
+
+// ExtendInstance extends the lifetime of an instance
+func ExtendInstance(instanceID string, additionalSeconds int64) error {
+ // Check max extension limit
+ maxExtension := cfg.Cfg.InstanceConfig.MaxExtension
+ if additionalSeconds > maxExtension {
+ additionalSeconds = maxExtension
+ }
+
+ additionalTime := time.Duration(additionalSeconds) * time.Second
+ return cache.ExtendInstance(instanceID, additionalTime)
+}
+
+// GetInstance retrieves an instance by ID
+func GetInstance(instanceID string) (*cache.Instance, error) {
+ return cache.GetInstance(instanceID)
+}
+
+// GetUserInstance retrieves a user's instance of a challenge
+func GetUserInstance(userID, challengeName string) (*cache.Instance, error) {
+ return cache.GetUserInstance(userID, challengeName)
+}
+
+// GetUserInstances retrieves all instances for a user
+func GetUserInstances(userID string) ([]*cache.Instance, error) {
+ return cache.GetUserInstances(userID)
+}
+
+// GetAllInstances retrieves all active instances (admin only)
+func GetAllInstances() ([]*cache.Instance, error) {
+ return cache.GetAllInstances()
+}
+
+// GetChallengeInstances retrieves all active instances for a specific challenge
+func GetChallengeInstances(challengeName string) ([]*cache.Instance, error) {
+ return cache.GetChallengeInstances(challengeName)
+}
+
+// KillChallengeInstances kills all active instances of a challenge.
+// This should be called when undeploying or purging a challenge.
+func KillChallengeInstances(challengeName string) error {
+ instances, err := cache.GetChallengeInstances(challengeName)
+ if err != nil {
+ return fmt.Errorf("failed to get instances for challenge %s: %w", challengeName, err)
+ }
+
+ if len(instances) == 0 {
+ log.Debugf("No active instances found for challenge %s", challengeName)
+ return nil
+ }
+
+ log.Infof("Killing %d active instance(s) for challenge %s", len(instances), challengeName)
+
+ var lastErr error
+ for _, instance := range instances {
+ log.Infof("Killing instance %s for user %s (challenge: %s)",
+ instance.InstanceID, instance.UserID, challengeName)
+
+ if err := KillInstance(instance.InstanceID); err != nil {
+ log.Warnf("Failed to kill instance %s: %v", instance.InstanceID, err)
+ lastErr = err
+ }
+ }
+
+ return lastErr
+}
+
+func allocateInstancePort(host string) (uint32, error) {
+ var firstPort, lastPort uint32
+ var err error
+
+ if host == core.LOCALHOST || host == "" {
+ firstPort, lastPort, err = utils.ParsePortMapping(cfg.Cfg.LocalHostPortRange)
+ } else {
+ /* This is the simplest, not the best, solution to this...
+ essentially selectServer returns the host, which is not necessarily the key used by AvailableServers so...
+ a better solution would be to treat localhost explicitly as a server so it can be generalised
+ (will also remove a lot of conditions scattered throughout the place) */
+ var server *cfg.AvailableServer
+ for _, s := range cfg.Cfg.AvailableServers {
+ if s.Host == host {
+ server = &s
+ break
+ }
+ }
+
+ if server == nil {
+ return 0, fmt.Errorf("no available server found for host %s", host)
+ }
+
+ firstPort, lastPort, err = utils.ParsePortMapping(server.PortRange)
+ }
+
+ if err != nil {
+ return 0, fmt.Errorf("failed to parse port range: %w", err)
+ }
+
+ portRange := lastPort - firstPort + 1
+ port, err := cache.GetFreePort(host, firstPort, portRange)
+ if err != nil {
+ return 0, fmt.Errorf("failed to allocate port: %w", err)
+ }
+
+ return port, nil
+}
+
+func freeInstancePort(host string, containerID string) {
+ err := cache.FreeContainerPorts(host, containerID)
+ if err != nil {
+ log.Warnf("Failed to free ports for container %s on %s: %v", containerID, host, err)
+ }
+}
+
+func selectServerForInstance() string {
+ availableServer, err := remoteManager.ServerQueue.GetNextAvailableInstance()
+ if err == nil && availableServer.Host != "" {
+ return availableServer.Host
+ }
+ return core.LOCALHOST
+}
+
+func verifyCheckLocal(containerId string) (string, error) {
+ fileCommand := fmt.Sprintf("[ -f '%s' ]", core.SAD_CHECK_SCRIPT_LOCATION)
+ chmodCommand := fmt.Sprintf("command chmod +x %s", core.SAD_CHECK_SCRIPT_LOCATION)
+ hashCommand := fmt.Sprintf("command cat %s | sha256sum", core.SAD_CHECK_SCRIPT_LOCATION)
+
+ result, err := cr.RunCommandInContainer(containerId, []string{
+ "sh", "-c", fileCommand,
+ })
+
+ if err != nil || result.ExitCode != 0 {
+ return "", fmt.Errorf("failed to verify 'check.sh' at location: %s", core.SAD_CHECK_SCRIPT_LOCATION)
+ }
+
+ result, err = cr.RunCommandInContainer(containerId, []string{
+ "sh", "-c", hashCommand,
+ })
+
+ if err != nil || result.ExitCode != 0 {
+ return "", fmt.Errorf("failed to hash 'check.sh' to store flag")
+ }
+
+ hash := result.Output
+
+ result, err = cr.RunCommandInContainer(containerId, []string{
+ "sh", "-c", chmodCommand,
+ })
+
+ if err != nil || result.ExitCode != 0 {
+ return "", fmt.Errorf("failed to make 'check.sh' executable at: %s", core.SAD_CHECK_SCRIPT_LOCATION)
+ }
+
+ return strings.TrimSpace(hash), nil
+}
+
+func verifyCheckRemote(containerId string, server cfg.AvailableServer) (string, error) {
+ fileCommand := fmt.Sprintf("[ -f '%s' ]", core.SAD_CHECK_SCRIPT_LOCATION)
+ chmodCommand := fmt.Sprintf("command chmod +x %s", core.SAD_CHECK_SCRIPT_LOCATION)
+ hashCommand := fmt.Sprintf("command cat %s | sha256sum", core.SAD_CHECK_SCRIPT_LOCATION)
+
+ result, err := remoteManager.RunCommandInContainerOnServer(server, containerId, fileCommand)
+
+ if err != nil || result.ExitCode != 0 {
+ return "", fmt.Errorf("failed to verify 'check.sh' at location: %s", core.SAD_CHECK_SCRIPT_LOCATION)
+ }
+
+ result, err = remoteManager.RunCommandInContainerOnServer(server, containerId, hashCommand)
+
+ if err != nil || result.ExitCode != 0 {
+ return "", fmt.Errorf("failed to hash 'check.sh' to store flag")
+ }
+
+ hash := result.Output
+
+ result, err = remoteManager.RunCommandInContainerOnServer(server, containerId, chmodCommand)
+
+ if err != nil || result.ExitCode != 0 {
+ return "", fmt.Errorf("failed to make 'check.sh' executable at: %s", core.SAD_CHECK_SCRIPT_LOCATION)
+ }
+
+ return strings.TrimSpace(hash), nil
+}
+
+func verifySSHLocal(containerId string) error {
+ return exec.Command("docker", "port", containerId, fmt.Sprintf("%v/tcp", core.SSH_PORT)).Run()
+}
+
+func verifySSHRemote(containerId string, server cfg.AvailableServer) error {
+ portCmd := fmt.Sprintf("docker port %s %v/tcp", containerId, core.SSH_PORT)
+ _, err := remoteManager.RunCommandOnServer(server, portCmd)
+ return err
+}
+
+func deployInstanceContainer(instanceID, challengeName string, hostPort uint32, imageID string, config *cfg.BeastChallengeConfig, serverDeployed string) (string, string, error) {
+ containerName := fmt.Sprintf("beast_instance_%s_%s", challengeName, instanceID)
+
+ containerPort := config.Challenge.Env.DefaultPort
+ if containerPort != core.SSH_PORT {
+ log.Warnln(fmt.Sprintf("Challenge %s does not have default port set to 22", challengeName))
+ containerPort = 22
+ }
+
+ portMapping := []cr.PortMapping{
+ {
+ HostPort: hostPort,
+ ContainerPort: containerPort,
+ },
+ }
+
+ var containerEnv []string
+ for _, env := range config.Challenge.Env.EnvironmentVars {
+ containerEnv = append(containerEnv, fmt.Sprintf("%s=%s", env.Key, filepath.Join(core.BEAST_DOCKER_CHALLENGE_DIR, env.Value)))
+ }
+
+ containerConfig := cr.CreateContainerConfig{
+ PortMapping: portMapping,
+ MountsMap: make(map[string]string),
+ ImageId: imageID,
+ ContainerName: containerName,
+ ChallengeName: challengeName,
+ ContainerEnv: containerEnv,
+ Traffic: config.Challenge.Env.TrafficType(),
+ CPUShares: config.Resources.CPUShares,
+ Memory: config.Resources.Memory,
+ PidsLimit: config.Resources.PidsLimit,
+ Labels: map[string]string{
+ "beast.instance": "true",
+ "beast.instance.id": instanceID,
+ },
+ }
+
+ var err error
+
+ var checkHash string
+ var containerId string
+
+ if serverDeployed == core.LOCALHOST || serverDeployed == "" {
+ containerId, err = cr.CreateContainerFromImage(&containerConfig)
+ if err != nil {
+ return "", "", fmt.Errorf("failed to create container from image: %w", err)
+ }
+
+ err = verifySSHLocal(containerId)
+ if err != nil {
+ return "", "", fmt.Errorf("failed to verify exposes of port %v in container %s on localhost", core.SSH_PORT, containerId)
+ }
+
+ checkHash, err = verifyCheckLocal(containerId)
+ if err != nil {
+ return "", "", fmt.Errorf("failed to verify check.sh at location %s in container: %s on localhost: %w", core.SAD_CHECK_SCRIPT_LOCATION, containerId, err)
+ }
+ } else {
+ server := cfg.Cfg.AvailableServers[serverDeployed]
+ containerId, err = remoteManager.CreateContainerFromImageRemote(containerConfig, server)
+ if err != nil {
+ return "", "", fmt.Errorf("failed to create container from image: %w", err)
+ }
+
+ err = verifySSHRemote(containerId, server)
+ if err != nil {
+ return "", "", fmt.Errorf("failed to verify exposes of port %v in container %s on host %s", core.SSH_PORT, containerId, server.Host)
+ }
+
+ checkHash, err = verifyCheckRemote(containerId, server)
+ if err != nil {
+ return "", "", fmt.Errorf("failed to verify check.sh at location %s in container: %s on host: %s: %w", core.SAD_CHECK_SCRIPT_LOCATION, containerId, server.Host, err)
+ }
+ }
+
+ return containerId, checkHash, nil
+}
+
+func deployInstanceFromCompose(instanceID, challengeName string, hostPort uint32, config *cfg.BeastChallengeConfig, stagingDir string, serverDeployed string) (string, string, error) {
+ projectName := fmt.Sprintf("beast-instance-%s-%s", coreUtils.EncodeID(challengeName), instanceID)
+ composeFile := filepath.Join(stagingDir, challengeName, config.Challenge.Env.DockerCompose)
+
+ var err error
+
+ var checkHash string
+ var containerId string
+
+ if serverDeployed == core.LOCALHOST || serverDeployed == "" {
+ err = utils.ValidateFileExists(composeFile)
+ if err != nil {
+ return "", "", fmt.Errorf("compose file not found: %w", err)
+ }
+
+ upCmd := exec.Command("docker", "compose",
+ "-f", composeFile,
+ "-p", projectName,
+ "up", "-d")
+
+ upCmd.Env = append(upCmd.Environ(), fmt.Sprintf("INSTANCE_PORT=%d", hostPort))
+
+ var upOutput bytes.Buffer
+ upCmd.Stdout = &upOutput
+ upCmd.Stderr = &upOutput
+
+ if err = upCmd.Run(); err != nil {
+ log.Errorf("docker compose up failed for instance %s. Output:\n%s", instanceID, upOutput.String())
+ return "", "", fmt.Errorf("docker compose up failed: %v", err)
+ }
+
+ psCmd := exec.Command("docker", "compose", "-p", projectName, "ps", "-q")
+ var output bytes.Buffer
+ psCmd.Stdout = &output
+
+ if err = psCmd.Run(); err != nil {
+ return "", "", fmt.Errorf("failed to get container IDs: %v", err)
+ }
+
+ containerIds := strings.Fields(strings.TrimSpace(output.String()))
+ if len(containerIds) == 0 {
+ return "", "", fmt.Errorf("no containers found for instance")
+ }
+
+ containerId = containerIds[0]
+ if len(containerId) >= 12 {
+ containerId = containerId[:12]
+ }
+
+ err = verifySSHLocal(containerId)
+ if err != nil {
+ return "", "", fmt.Errorf("failed to verify exposes of port %v in container %s on localhost", core.SSH_PORT, containerId)
+ }
+
+ checkHash, err = verifyCheckLocal(containerId)
+ if err != nil {
+ return "", "", fmt.Errorf("failed to verify check.sh at location %s in container: %s on localhost: %w", core.SAD_CHECK_SCRIPT_LOCATION, containerId, err)
+ }
+ } else {
+ server := cfg.Cfg.AvailableServers[serverDeployed]
+ containerId, err = remoteManager.DeployContainerFromComposeRemote(challengeName, stagingDir, config.Challenge.Env.DockerCompose, server)
+ if err != nil {
+ return "", "", fmt.Errorf("failed to deploy compose on remote: %w", err)
+ }
+
+ err = verifySSHRemote(containerId, server)
+ if err != nil {
+ return "", "", fmt.Errorf("failed to verify exposes of port %v in container %s on host %s", core.SSH_PORT, containerId, server.Host)
+ }
+
+ checkHash, err = verifyCheckRemote(containerId, server)
+ if err != nil {
+ return "", "", fmt.Errorf("failed to verify check.sh at location %s in container: %s on host: %s: %w", core.SAD_CHECK_SCRIPT_LOCATION, containerId, server.Host, err)
+ }
+ }
+
+ return containerId, checkHash, nil
+}
+
+func killInstanceContainer(containerID, deploymentType, instanceID, challengeName, serverDeployed string) error {
+ if serverDeployed == core.LOCALHOST || serverDeployed == "" {
+ if deploymentType == core.DEPLOYMENT_TYPES["docker_compose"] {
+ projectName := fmt.Sprintf("beast-instance-%s-%s", coreUtils.EncodeID(challengeName), instanceID)
+ downCmd := exec.Command("docker", "compose", "-p", projectName, "down", "--remove-orphans", "-v")
+
+ var output bytes.Buffer
+ downCmd.Stdout = &output
+ downCmd.Stderr = &output
+
+ if err := downCmd.Run(); err != nil {
+ return fmt.Errorf("docker compose down failed: %v, output: %s", err, output.String())
+ }
+ } else {
+ err := cr.StopAndRemoveContainer(containerID)
+ if err != nil {
+ return fmt.Errorf("failed to stop container: %w", err)
+ }
+ }
+ } else {
+ server := cfg.Cfg.AvailableServers[serverDeployed]
+ if deploymentType == core.DEPLOYMENT_TYPES["docker_compose"] {
+ stagingDir := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_STAGING_DIR)
+ err := remoteManager.ComposePurgeRemote(challengeName, stagingDir, server)
+ if err != nil {
+ return fmt.Errorf("failed to stop compose on remote: %w", err)
+ }
+ } else {
+ err := remoteManager.StopAndRemoveContainerRemote(containerID, server)
+ if err != nil {
+ return fmt.Errorf("failed to stop container on remote: %w", err)
+ }
+ }
+ }
+
+ return nil
+}
+
+func getHostedAddress(serverDeployed string) string {
+ if serverDeployed != "" && serverDeployed != core.LOCALHOST {
+ return serverDeployed
+ }
+ if cfg.Cfg.BeastStaticUrl != "" {
+ return cfg.Cfg.BeastStaticUrl
+ }
+ return "localhost"
+}
diff --git a/core/manager/pipeline.go b/core/manager/pipeline.go
index f13eb40d..56457df0 100644
--- a/core/manager/pipeline.go
+++ b/core/manager/pipeline.go
@@ -7,6 +7,8 @@ import (
"path/filepath"
"time"
+ "github.com/sdslabs/beastv4/core/cache"
+
"github.com/sdslabs/beastv4/core"
cfg "github.com/sdslabs/beastv4/core/config"
"github.com/sdslabs/beastv4/core/database"
@@ -295,13 +297,13 @@ func deployChallenge(challenge *database.Challenge, config cfg.BeastChallengeCon
if challenge.ServerDeployed == core.LOCALHOST || challenge.ServerDeployed == "" {
staticMountDir = filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_STAGING_DIR, config.Challenge.Metadata.Name, core.BEAST_STATIC_FOLDER)
} else {
- staticMountDir = filepath.Join("$HOME/.beast", core.BEAST_STAGING_DIR, config.Challenge.Metadata.Name, core.BEAST_STATIC_FOLDER)
+ staticMountDir = filepath.Join(core.BEAST_REMOTE_GLOBAL_DIR, core.BEAST_STAGING_DIR, config.Challenge.Metadata.Name, core.BEAST_STATIC_FOLDER)
}
relativeStaticContentDir := config.Challenge.Env.StaticContentDir
if relativeStaticContentDir == "" {
relativeStaticContentDir = core.PUBLIC
}
- staticMount[staticMountDir] = filepath.Join("/challenge", relativeStaticContentDir)
+ staticMount[staticMountDir] = filepath.Join(core.BEAST_DOCKER_CHALLENGE_DIR, relativeStaticContentDir)
log.Debugf("Static mount config for deploy : %s", staticMount)
var containerEnv []string
@@ -318,9 +320,39 @@ func deployChallenge(challenge *database.Challenge, config cfg.BeastChallengeCon
config.Resources.PidsLimit,
)
- portMapping, err := config.Challenge.Env.GetPortMappings()
+ var err error
+ var host string
+ var firstPort, lastPort uint32
+ if challenge.ServerDeployed == core.LOCALHOST || challenge.ServerDeployed == "" {
+ host = core.LOCALHOST
+ firstPort, lastPort, err = utils.ParsePortMapping(cfg.Cfg.LocalHostPortRange)
+ } else {
+ server := GetServerFromHost(challenge.ServerDeployed)
+
+ host = server.Host
+ firstPort, lastPort, err = utils.ParsePortMapping(server.PortRange)
+ }
+
if err != nil {
- return fmt.Errorf("error while parsing port mapping for the challenge %s: %s", config.Challenge.Metadata.Name, err)
+ return fmt.Errorf("error while allocating ports on server %s for challenge %s: %s", host, challenge.Name, err.Error())
+ }
+
+ /* both ports are inclusive */
+ portRange := lastPort - firstPort + 1
+
+ ports := config.Challenge.Env.Ports
+ portMapping := make([]cr.PortMapping, len(ports))
+
+ for i, containerPort := range ports {
+ hostPort, err := cache.GetFreePort(host, firstPort, portRange)
+ if err != nil {
+ return fmt.Errorf("error while getting free port on host %s: %s", host, err)
+ }
+
+ portMapping[i] = cr.PortMapping{
+ HostPort: hostPort,
+ ContainerPort: containerPort,
+ }
}
containerConfig := cr.CreateContainerConfig{
@@ -345,10 +377,13 @@ func deployChallenge(challenge *database.Challenge, config cfg.BeastChallengeCon
}
if err != nil {
- if containerId != "" {
- return fmt.Errorf("error while starting the container : %s", err)
+ return fmt.Errorf("error while creating container for challenge %s: %s", challenge.Name, err.Error())
+ }
+
+ for _, portMap := range portMapping {
+ if err := cache.RegisterFreePort(host, containerId, portMap.HostPort); err != nil {
+ return fmt.Errorf("error while registering port %v on host %s: %s", portMap.HostPort, host, err)
}
- return fmt.Errorf("error while trying to create a container for the challenge: %s", err)
}
if err = database.UpdateChallenge(challenge, map[string]any{
@@ -522,6 +557,12 @@ func bootstrapDeployPipeline(challengeDir string, skipStage bool, skipCommit boo
log.Debugf("Skipping commit phase")
}
+ if challenge.Instanced {
+ database.UpdateChallenge(&challenge, map[string]interface{}{"status": core.DEPLOY_STATUS["deployed"]})
+ log.Infof("Challenge %s is instanced, skipping deploy stage", challengeName)
+ return nil
+ }
+
database.UpdateChallenge(&challenge, map[string]interface{}{"status": core.DEPLOY_STATUS["deploying"]})
err = deployChallenge(&challenge, config)
diff --git a/core/manager/sync.go b/core/manager/sync.go
index ef47b72b..12c7c185 100644
--- a/core/manager/sync.go
+++ b/core/manager/sync.go
@@ -67,7 +67,6 @@ func SyncBeastRemote(defaultauthorpassword string) error {
}
}
log.Info("Beast git base synced with remote")
- go config.UpdateUsedPortList()
UpdateChallenges(defaultauthorpassword)
return fmt.Errorf("%s", strings.Join(errStrings, "\n"))
}
@@ -187,7 +186,6 @@ func SyncAndGetChangesFromRemote(defaultauthorpassword string) []string {
}
}
log.Info("Beast git base synced with remote")
- go config.UpdateUsedPortList()
UpdateChallenges(defaultauthorpassword)
return modifiedChallsNameList
diff --git a/core/manager/utils.go b/core/manager/utils.go
index 9861d251..917bed2d 100644
--- a/core/manager/utils.go
+++ b/core/manager/utils.go
@@ -4,6 +4,7 @@ import (
"archive/zip"
"bytes"
"fmt"
+ "github.com/sdslabs/beastv4/core/cache"
"io"
"io/ioutil"
"os"
@@ -504,26 +505,28 @@ func UpdateOrCreateChallengeDbEntry(challEntry *database.Challenge, config cfg.B
}
*challEntry = database.Challenge{
- Name: config.Challenge.Metadata.Name,
- AuthorID: userEntry.ID,
- Format: config.Challenge.Metadata.Type,
- Status: core.DEPLOY_STATUS["undeployed"],
- ContainerId: coreUtils.GetTempContainerId(config.Challenge.Metadata.Name),
- ImageId: coreUtils.GetTempImageId(config.Challenge.Metadata.Name),
- MaxAttemptLimit: config.Challenge.Metadata.MaxAttemptLimit,
- PreReqs: strings.Join(config.Challenge.Metadata.PreReqs, core.DELIMITER),
- DynamicFlag: config.Challenge.Metadata.DynamicFlag,
- Flag: config.Challenge.Metadata.Flag,
- Type: config.Challenge.Metadata.Type,
- Description: config.Challenge.Metadata.Description,
- Assets: strings.Join(assetsURL, core.DELIMITER),
- AdditionalLinks: strings.Join(config.Challenge.Metadata.AdditionalLinks, core.DELIMITER),
- Points: config.Challenge.Metadata.Points,
- MinPoints: config.Challenge.Metadata.MinPoints,
- MaxPoints: config.Challenge.Metadata.MaxPoints,
- Difficulty: config.Challenge.Metadata.Difficulty,
- ServerDeployed: availableServerHostname,
- DeploymentType: deploymentType,
+ Name: config.Challenge.Metadata.Name,
+ AuthorID: userEntry.ID,
+ Format: config.Challenge.Metadata.Type,
+ Status: core.DEPLOY_STATUS["undeployed"],
+ ContainerId: coreUtils.GetTempContainerId(config.Challenge.Metadata.Name),
+ ImageId: coreUtils.GetTempImageId(config.Challenge.Metadata.Name),
+ MaxAttemptLimit: config.Challenge.Metadata.MaxAttemptLimit,
+ PreReqs: strings.Join(config.Challenge.Metadata.PreReqs, core.DELIMITER),
+ DynamicFlag: config.Challenge.Metadata.DynamicFlag,
+ Flag: config.Challenge.Metadata.Flag,
+ Type: config.Challenge.Metadata.Type,
+ Description: config.Challenge.Metadata.Description,
+ Assets: strings.Join(assetsURL, core.DELIMITER),
+ AdditionalLinks: strings.Join(config.Challenge.Metadata.AdditionalLinks, core.DELIMITER),
+ Points: config.Challenge.Metadata.Points,
+ MinPoints: config.Challenge.Metadata.MinPoints,
+ MaxPoints: config.Challenge.Metadata.MaxPoints,
+ Difficulty: config.Challenge.Metadata.Difficulty,
+ ServerDeployed: availableServerHostname,
+ DeploymentType: deploymentType,
+ Instanced: config.Challenge.Metadata.Instanced,
+ InstanceExpiration: config.Challenge.Metadata.InstanceExpiration,
}
err = database.CreateChallengeEntry(challEntry)
@@ -566,37 +569,46 @@ func UpdateOrCreateChallengeDbEntry(challEntry *database.Challenge, config cfg.B
return false
}
- hostPorts, err := config.Challenge.Env.GetAllHostPorts()
- if err != nil {
- return fmt.Errorf("error while parsing host port for challenge %s : %s", challEntry.Name, err)
- }
- // Once the challenge entry has been created, add entries to the ports
- // table in the database with the ports to expose
- // for the challenge.
- // TODO: Do all this under a database transaction so that if any port
- // request is not available
- for _, port := range hostPorts {
- if isAllocated(port) {
- // The port has already been allocated to the challenge
- // Do nothing for this.
- continue
- }
-
- portEntry := database.Port{
- ChallengeID: challEntry.ID,
- PortNo: port,
+ if challEntry.ContainerId != "" {
+ var host string
+ if challEntry.ServerDeployed == core.LOCALHOST || challEntry.ServerDeployed == "" {
+ host = core.LOCALHOST
+ } else {
+ host = cfg.Cfg.AvailableServers[challEntry.ServerDeployed].Host
}
- gotPort, err := database.PortEntryGetOrCreate(&portEntry)
+ hostPorts, err := cache.GetContainerPorts(host, challEntry.ContainerId)
if err != nil {
- return err
- }
+ return fmt.Errorf("error while parsing host port for challenge %s : %s", challEntry.Name, err)
+ }
+ // Once the challenge entry has been created, add entries to the ports
+ // table in the database with the ports to expose
+ // for the challenge.
+ // TODO: Do all this under a database transaction so that if any port
+ // request is not available
+ for _, port := range hostPorts {
+ if isAllocated(port) {
+ // The port has already been allocated to the challenge
+ // Do nothing for this.
+ continue
+ }
- // var gotChall database.Challenge
- // database.Db.Model(&gotPort).Related(&gotChall)
+ portEntry := database.Port{
+ ChallengeID: challEntry.ID,
+ PortNo: port,
+ }
- if gotPort.ChallengeID != challEntry.ID {
- return fmt.Errorf("the port %d requested is already in use by another challenge", gotPort.PortNo)
+ gotPort, err := database.PortEntryGetOrCreate(&portEntry)
+ if err != nil {
+ return err
+ }
+
+ // var gotChall database.Challenge
+ // database.Db.Model(&gotPort).Related(&gotChall)
+
+ if gotPort.ChallengeID != challEntry.ID {
+ return fmt.Errorf("the port %d requested is already in use by another challenge", gotPort.PortNo)
+ }
}
}
@@ -930,3 +942,17 @@ func ValidateFlag(flag, challenge_name string) error {
}
return nil
}
+
+/*
+The better solution is to add a parameter to AvailableServer to store the key itself
+but should be hanlded in a different PR
+*/
+func GetServerFromHost(host string) *cfg.AvailableServer {
+ for _, server := range cfg.Cfg.AvailableServers {
+ if server.Host == host {
+ return &server
+ }
+ }
+
+ return nil
+}
diff --git a/go.mod b/go.mod
index cb7ee2f7..21e0d0cd 100644
--- a/go.mod
+++ b/go.mod
@@ -12,7 +12,6 @@ require (
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/protobuf v1.3.3
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.5.5
github.com/jinzhu/gorm v1.9.1
@@ -20,6 +19,7 @@ require (
github.com/manifoldco/promptui v0.9.0
github.com/mohae/struct2csv v0.0.0-20151122200941-e72239694eae
github.com/olekukonko/tablewriter v0.0.5
+ github.com/redis/go-redis/v9 v9.17.3
github.com/sirupsen/logrus v1.9.0
github.com/spf13/cobra v0.0.3
github.com/swaggo/gin-swagger v1.0.0
@@ -27,7 +27,6 @@ require (
golang.org/x/crypto v0.29.0
golang.org/x/net v0.31.0
golang.org/x/term v0.26.0
- google.golang.org/grpc v1.19.0
gopkg.in/src-d/go-git.v4 v4.7.0
gorm.io/driver/postgres v1.5.11
gorm.io/gorm v1.25.10
@@ -40,6 +39,7 @@ require (
github.com/alcortesm/tgz v0.0.0-20161220082320-9c5fe88206d7 // indirect
github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.8.0 // indirect
@@ -48,6 +48,7 @@ require (
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect
github.com/cpuguy83/go-md2man v1.0.10 // indirect
github.com/denisenkom/go-mssqldb v0.0.0-20180901172138-1eb28afdf9b6 // indirect
+ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/docker/distribution v2.8.1+incompatible // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
@@ -65,6 +66,7 @@ require (
github.com/go-playground/validator/v10 v10.4.1 // indirect
github.com/go-sql-driver/mysql v1.4.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
+ github.com/golang/protobuf v1.3.3 // indirect
github.com/inconshreveable/mousetrap v1.0.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
@@ -109,7 +111,6 @@ require (
golang.org/x/time v0.3.0 // indirect
golang.org/x/tools v0.27.0 // indirect
google.golang.org/appengine v1.2.0 // indirect
- google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8 // indirect
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
diff --git a/go.sum b/go.sum
index 410c894b..cf05334d 100644
--- a/go.sum
+++ b/go.sum
@@ -1,9 +1,7 @@
-cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.28.0 h1:KZ/88LWSw8NxMkjdQyX7LQSGR9PkHr4PaVuNm8zgFq0=
cloud.google.com/go v0.28.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
-github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0=
github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
@@ -20,6 +18,12 @@ github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhP
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
+github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
+github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
+github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
+github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI=
@@ -40,7 +44,6 @@ github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5O
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
-github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cpuguy83/go-md2man v1.0.10 h1:BSKMNlYxDvnunlTymqtgONjNnaRV1sTpcovwwjF22jk=
github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -50,6 +53,8 @@ github.com/denisenkom/go-mssqldb v0.0.0-20180901172138-1eb28afdf9b6 h1:BZGp1dbKF
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=
github.com/docker/distribution v2.8.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
github.com/docker/docker v20.10.22+incompatible h1:6jX4yB+NtcbldT90k7vBSaWJDB3i+zkVJT9BEK8kQkk=
@@ -100,9 +105,6 @@ 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/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58=
-github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
-github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
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=
@@ -202,6 +204,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/redis/go-redis/v9 v9.17.3 h1:fN29NdNrE17KttK5Ndf20buqfDZwGNgoUr9qjl1DQx4=
+github.com/redis/go-redis/v9 v9.17.3/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
@@ -247,27 +251,22 @@ golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ=
golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
-golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo=
golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM=
-golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
-golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
-golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180903190138-2b024373dcd9/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -291,7 +290,6 @@ golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
@@ -302,13 +300,8 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.2.0 h1:S0iUepdCWODXRvtE+gcRDd15L+k+k1AiHlMiMjefH24=
google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
-google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8 h1:Nw54tB0rB7hY/N0NQvRW8DG4Yk3Q6T9cu9RcFQDu1tc=
-google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
-google.golang.org/grpc v1.19.0 h1:cfg4PD8YEdSFnm7qLV4++93WcmhH2nIUhMjhdCvl3j8=
-google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
@@ -336,4 +329,3 @@ gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s=
gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gotest.tools/v3 v3.4.0 h1:ZazjZUfuVeZGLAmlKKuyv3IKP5orXcwtOwDQH6YVr6o=
gotest.tools/v3 v3.4.0/go.mod h1:CtbdzLSsqVhDgMtKsx03ird5YTGB3ar27v0u/yKBW5g=
-honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
diff --git a/pkg/cr/containers.go b/pkg/cr/containers.go
index ea102da8..1858a0bb 100644
--- a/pkg/cr/containers.go
+++ b/pkg/cr/containers.go
@@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"fmt"
+ "io"
"io/ioutil"
"os/exec"
"path/filepath"
@@ -28,6 +29,11 @@ type PortMapping struct {
ContainerPort uint32
}
+type ExecResult struct {
+ ExitCode int
+ Output string
+}
+
// TrafficType is the protocol supported by container ingress and egress through
// the port mappings.
type TrafficType string
@@ -66,6 +72,7 @@ type CreateContainerConfig struct {
ContainerEnv []string
ContainerNetwork string
Traffic TrafficType
+ Labels map[string]string
CPUShares int64
Memory int64
@@ -172,16 +179,21 @@ func CreateContainerFromImage(containerConfig *CreateContainerConfig) (string, e
}}
}
+ labels := map[string]string{
+ "beast.challenge": containerConfig.ChallengeName,
+ "com.sdslabs.beast.project": utils.GetProjectName(containerConfig.ChallengeName),
+ "com.docker.compose.project": utils.GetProjectName(containerConfig.ChallengeName),
+ "com.sdslabs.beast.challenge": containerConfig.ChallengeName,
+ }
+ for k, v := range containerConfig.Labels {
+ labels[k] = v
+ }
+
config := &container.Config{
Image: containerConfig.ImageId,
ExposedPorts: portSet,
Env: containerConfig.ContainerEnv,
- Labels: map[string]string{
- "beast.challenge": containerConfig.ChallengeName,
- "com.sdslabs.beast.project": utils.GetProjectName(containerConfig.ChallengeName),
- "com.docker.compose.project": utils.GetProjectName(containerConfig.ChallengeName),
- "com.sdslabs.beast.challenge": containerConfig.ChallengeName,
- },
+ Labels: labels,
}
var mountBindings []mount.Mount
@@ -293,6 +305,59 @@ func CommitContainer(containerId string) (string, error) {
return commitResp.ID, nil
}
+func RunCommandInContainer(containerID string, cmd []string) (ExecResult, error) {
+ result := ExecResult{
+ ExitCode: 1,
+ }
+
+ cli, err := client.NewEnvClient()
+ if err != nil {
+ return result, err
+ }
+
+ ctx := context.Background()
+
+ execResp, err := cli.ContainerExecCreate(
+ ctx,
+ containerID,
+ types.ExecConfig{
+ Cmd: cmd,
+ Tty: true,
+ AttachStdout: true,
+ AttachStderr: true,
+ },
+ )
+ if err != nil {
+ return result, err
+ }
+
+ resp, err := cli.ContainerExecAttach(
+ ctx,
+ execResp.ID,
+ types.ExecStartCheck{},
+ )
+ if err != nil {
+ return result, err
+ }
+ defer resp.Close()
+
+ var output bytes.Buffer
+ _, err = io.Copy(&output, resp.Reader)
+ if err != nil {
+ return result, err
+ }
+
+ result.Output = output.String()
+
+ inspect, err := cli.ContainerExecInspect(ctx, execResp.ID)
+ if err != nil {
+ return result, err
+ }
+
+ result.ExitCode = inspect.ExitCode
+ return result, nil
+}
+
func DeployContainerFromCompose(challengeName, stagedPath, composeFileName string) (string, error) {
extractDir := filepath.Join(stagedPath, challengeName)
projectName := utils.GetProjectName(challengeName)
diff --git a/pkg/remoteManager/init.go b/pkg/remoteManager/init.go
index 2a9aa893..d3bd43a3 100644
--- a/pkg/remoteManager/init.go
+++ b/pkg/remoteManager/init.go
@@ -1,9 +1,11 @@
package remoteManager
import (
+ "fmt"
"github.com/sdslabs/beastv4/core"
"github.com/sdslabs/beastv4/core/config"
log "github.com/sirupsen/logrus"
+ "path/filepath"
)
func Init() {
@@ -21,7 +23,10 @@ func Init() {
}
defer client.Close()
ServerQueue.Push(server)
- RunCommandOnServer(server, "mkdir -p $HOME/.beast/staging/")
+ _, err = RunCommandOnServer(server, fmt.Sprintf("mkdir -p %s", filepath.Join(core.BEAST_REMOTE_GLOBAL_DIR, core.BEAST_STAGING_DIR)))
+ if err != nil {
+ log.Errorf("fialed to run command on server %s: %s", server.Host, err.Error())
+ }
}
}
}
diff --git a/pkg/remoteManager/ssh.go b/pkg/remoteManager/ssh.go
index 30871e53..cd0a4abd 100644
--- a/pkg/remoteManager/ssh.go
+++ b/pkg/remoteManager/ssh.go
@@ -3,7 +3,9 @@ package remoteManager
import (
"errors"
"fmt"
+ "github.com/sdslabs/beastv4/pkg/cr"
"io/ioutil"
+ "strings"
"sync"
"github.com/sdslabs/beastv4/core/config"
@@ -83,8 +85,8 @@ func RunCommandOnServer(server config.AvailableServer, cmd string) (string, erro
if err != nil {
return "", fmt.Errorf("failed to create session: %s", err)
}
- defer client.Close()
defer session.Close()
+ defer client.Close()
output, err := session.CombinedOutput(cmd)
if err != nil {
@@ -95,6 +97,44 @@ func RunCommandOnServer(server config.AvailableServer, cmd string) (string, erro
return string(output), nil
}
+// Runs a command in a container on a remote server
+func RunCommandInContainerOnServer(server config.AvailableServer, containerId string, cmd string) (cr.ExecResult, error) {
+ result := cr.ExecResult{
+ ExitCode: 0,
+ }
+
+ if !server.Active {
+ return result, fmt.Errorf("server is inactive in config.toml")
+ }
+ client, err := CreateSSHClient(server)
+ if err != nil {
+ return result, fmt.Errorf("failed to create session: %s", err)
+ }
+ session, err := client.NewSession()
+ if err != nil {
+ return result, fmt.Errorf("failed to create session: %s", err)
+ }
+ defer session.Close()
+ defer client.Close()
+
+ cmd = strings.ReplaceAll(cmd, `'`, `'\''`)
+
+ dockerCmd := fmt.Sprintf("docker exec %s sh -c '%s'", containerId, cmd)
+ output, err := session.CombinedOutput(dockerCmd)
+ if err != nil {
+ var exitErr *ssh.ExitError
+ if errors.As(err, &exitErr) {
+ result.ExitCode = exitErr.ExitStatus()
+ }
+ }
+
+ result.Output = string(output)
+
+ log.Debugln(fmt.Sprintf("Command output for cmd %s in container %s on host %s: %s", cmd, containerId, server.Host, result.Output))
+ log.Debugln(fmt.Sprintf("Exit Code for cmd %s in container %s on host %s: %v", cmd, containerId, server.Host, result.ExitCode))
+ return result, nil
+}
+
// Creates an SSH client to connect to the remote server.
func CreateSSHClient(remoteServer config.AvailableServer) (*ssh.Client, error) {
if !remoteServer.Active {
diff --git a/utils/cache.go b/utils/cache.go
new file mode 100644
index 00000000..58ab63ea
--- /dev/null
+++ b/utils/cache.go
@@ -0,0 +1,11 @@
+package utils
+
+import "fmt"
+
+func HostToKey(host string) string {
+ return fmt.Sprintf("host:%s", host)
+}
+
+func ContainerToKey(host string, containerId string) string {
+ return fmt.Sprintf("host:%s:container:%s", host, containerId)
+}
diff --git a/utils/datatypes.go b/utils/datatypes.go
index df2d48d7..4bf4ddd9 100644
--- a/utils/datatypes.go
+++ b/utils/datatypes.go
@@ -46,7 +46,7 @@ func UInt32InList(a uint32, list []uint32) bool {
// ParsePortMapping parses the port mapping string and return the required ports
// If the portMapping string is not valid, this returns an error.
-// The format of the port mapping is `HOST_PORT:CONTAINER_PORT`
+// The format of the port mapping is `PORT_FIRST:PORT_LAST`
func ParsePortMapping(portMap string) (uint32, uint32, error) {
ports := strings.Split(portMap, mappingDelimeter)
@@ -54,15 +54,15 @@ func ParsePortMapping(portMap string) (uint32, uint32, error) {
return 0, 0, errors.New("port mapping string is not valid")
}
- hostPort, err := strconv.ParseUint(ports[0], 10, 32)
+ firstPort, err := strconv.ParseUint(ports[0], 10, 32)
if err != nil {
return 0, 0, fmt.Errorf("host port is not a valid port in: %s", portMap)
}
- containerPort, err := strconv.ParseUint(ports[1], 10, 32)
+ secondPort, err := strconv.ParseUint(ports[1], 10, 32)
if err != nil {
return 0, 0, fmt.Errorf("container port is not a valid port in: %s", portMap)
}
- return uint32(hostPort), uint32(containerPort), nil
+ return uint32(firstPort), uint32(secondPort), nil
}
diff --git a/utils/prompt.go b/utils/prompt.go
index 5342906b..cc0178c3 100644
--- a/utils/prompt.go
+++ b/utils/prompt.go
@@ -3,13 +3,14 @@ package utils
import (
"bufio"
"fmt"
- "github.com/manifoldco/promptui"
- log "github.com/sirupsen/logrus"
- "golang.org/x/term"
"os"
"strconv"
"syscall"
"time"
+
+ "github.com/manifoldco/promptui"
+ log "github.com/sirupsen/logrus"
+ "golang.org/x/term"
)
var binaryOptions = []string{
@@ -57,6 +58,7 @@ func PromptInt64(prompt string, defaultValue int64) int64 {
if temp == "" {
log.Warnln(fmt.Sprintf("Input empty.. defaulting to %v...", defaultValue))
+ return defaultValue
} else if err != nil {
log.Errorln(fmt.Sprintf("Failed to read input... defaulting to %v...", defaultValue))
return defaultValue