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