From ed178bab7d9415648eebf4046c606fbde49bca32 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Wed, 15 Jul 2026 22:49:21 +0530 Subject: [PATCH 001/149] fix(build): remove privileged namespace helpers Signed-off-by: vibhatsu --- Makefile | 24 ++------------------ scripts/docker-enter | 52 ------------------------------------------- scripts/docker_enter | Bin 8488 -> 0 bytes scripts/importenv.c | 49 ---------------------------------------- 4 files changed, 2 insertions(+), 123 deletions(-) delete mode 100755 scripts/docker-enter delete mode 100755 scripts/docker_enter delete mode 100644 scripts/importenv.c diff --git a/Makefile b/Makefile index 00c9f9f8..51fcdad9 100644 --- a/Makefile +++ b/Makefile @@ -12,11 +12,10 @@ help: @echo "* check_format: Check for formatting errors using gofmt" @echo "* format: format the go files using go_fmt in the project directory." @echo "* test: Run tests for beast" - @echo "* tools: Set up required tools for beast which includes - docker-enter, importenv" @echo "" # Build beast -build: tools +build: @./scripts/build/build.sh # Run development environment @@ -47,25 +46,6 @@ govet: @echo "[*] Vetting code, checking for mistakes" @$(GO) vet $(pkgs) -# Ensure that the required tools are installed for beast to work -tools: - @if ! test -x "`which nsenter 2>&1;true`"; then \ - echo 'Error: nsenter is not installed, Install it first' >&2 ; \ - fi - - @if ! test -x "`which docker-enter 2>&1;true`"; then \ - echo 'Warn: docker-enter is not installed, building....' >&2 ; \ - sudo cp ./scripts/docker-enter "/usr/bin/" ; \ - sudo cp ./scripts/docker_enter "/usr/bin/"; \ - sudo chown root "/usr/bin/docker_enter"; \ - sudo chmod u+s "/usr/bin/docker_enter"; \ - fi - - @if ! test -x "`which importenv 2>&1;true`"; then \ - echo 'Warn: importenv is not installed, building....' >&2 ; \ - sudo gcc -o "/usr/bin/importenv" ./scripts/importenv.c ; \ - fi - requirements: @echo ">>> Building beast extras..." @./scripts/build/extras.sh @@ -80,4 +60,4 @@ installenv: @echo 'Setting up environment for beast.' @./scripts/installenv.sh -.PHONY: build format test check_format tools docs installenv +.PHONY: build format test check_format docs installenv diff --git a/scripts/docker-enter b/scripts/docker-enter deleted file mode 100755 index 26e23996..00000000 --- a/scripts/docker-enter +++ /dev/null @@ -1,52 +0,0 @@ -#!/bin/sh -# This file is an almost fork of https://github.com/jpetazzo/nsenter/blob/master/docker-enter - -if [ -e $(dirname "$0")/nsenter ]; then - # with boot2docker, nsenter is not in the PATH but it is in the same folder - NSENTER=$(dirname "$0")/nsenter -else - NSENTER=nsenter -fi - -if [ -e $(dirname "$0")/importenv ]; then - # with boot2docker, importenv is not in the PATH but it is in the same folder - IMPORTENV=$(dirname "$0")/importenv -else - IMPORTENV=importenv -fi - -if [ -z "$1" ]; then - echo "Usage: `basename "$0"` CONTAINER [COMMAND [ARG]...]" - echo "" - echo "Enters the Docker CONTAINER and executes the specified COMMAND." - echo "If COMMAND is not specified, runs an interactive shell in CONTAINER." - exit -fi - -PID=$(docker inspect --format "{{.State.Pid}}" "$1") -[ -z "$PID" ] && exit 1 -shift - -if [ "$(id -u)" -ne "0" ]; then - which sudo > /dev/null - if [ "$?" -eq "0" ]; then - LAZY_SUDO="sudo " - else - echo "Warning: Cannot find sudo; Invoking nsenter as the user $USER." >&2 - fi -fi - -ENVIRON="/proc/$PID/environ" - -# Prepare nsenter flags -OPTS="--target $PID --mount --uts --ipc --net --pid --" - -# env is to clear all host environment variables and set then anew -if [ $# -lt 1 ]; then - # No arguments, default to `su` which executes the default login shell - $LAZY_SUDO "$IMPORTENV" "$ENVIRON" "$NSENTER" $OPTS su -m root -else - # Has command - # "$@" is magic in bash, and needs to be in the invocation - $LAZY_SUDO "$IMPORTENV" "$ENVIRON" "$NSENTER" $OPTS "$@" -fi diff --git a/scripts/docker_enter b/scripts/docker_enter deleted file mode 100755 index eb5caf684b3da7e18faa38d021ba2c265af1d85c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8488 zcmeHMdu&tZ75{7#a3FD99&LbbcswRx%1y!}gcjO3iNnp5gp?+9)e4sz+etk9i0tbv zLF*bVZH1v>{IN;-XByQsl}-DDy`-%BV?jYY{u=zTF=Z%2>Ysd)eZKQMkMI5Ye8;baI@;VWm*CH0;EYaigNh=oOoKG z+7gMG@+Omj)GQClHB&F)9-wH~=r#c-eVX)`au1P~k*pL^mzyY4Ap#OWJM8O3rDTBy zWQFN4^<$JM+aZf~=gICo*)g4^jxpu&;n?ugL;jq!6=YbLqAr&14~&Scmzz9Hv{{Jm zBbF)Ky#RK!WTf%Q#82Ww)ZSwAGD!VrO2ZYk@mOEOuG)B{CLT*>hiir#8)_PMDVdbA zLyjB$qwayOUfB|^H%~40c{O|xL%DvwI=*xJ>mQxiq;9;~egAvqXU-JRmOoJapGyKX z10}Rq0)`)Fc?y71c zA`>;Tv4|MT8X2K!8AA^r*TRFxwSGMo7a1cR)(w#v$r#ZDh{MCW)*nmi@z{wdu;>L% z(uS7MV@YwKqrJIB+o9|#)^;enh1TBFsYRmc=s*nIqZng3&ziR0OENe%PswgtD|H+c(gJ#VuXoZCQ84=p%dO|pRtgk$&kT%cgm zg5&w%GG)Q}%%kk81*f4(lj|0ouMiZdWB>4~r_1h^2ZZ{@l;JL1SH~`Sr=<;k6?UHj zQDMtl@Ljc`32Mj>qG;xJ0m_zdBac%xb6xT$kjEj+Oi4bCJa&ENg5d2BudX zFM9!~y7!@)`@mQWLt2h345{)+;dZ}Lwc&N?|Hw3sVx9+=Yf*80_e$wz+Z@=d7w0@` zZdSdRe_nN6R&UH1)!^`k*K}Bc4j1z`y8jxeiEOPpw*MWh^vGxKz`L+Jqu8-~)8G=ugX%|S`auJ0usU_QS8u7g?DdFxddZfppsD6U59_DKUcKdd zZ58x>6U&;&Y z%|??(wEs%z<8CT4{b!Ua_ zmr<7g@KK>~ZF+ukt2T(>OTokKxtqb>Agtw*bH*iAN^0Ea13>Q{x)Crm#bEM!#gUTU-#_xotrlzjP|f?#3bl>5uk#$ufXSB zXz$mE1%3WE-7S?%-M@q$nKs`9_!x{Oit=Wk|2ytxU-cPJ$QKx2((K#%R+;LndvmGk zYaCnF>1#^+8iT&Npl@rlF93R=-|X|syZ92Ap9DYVOrx{MU zVvJHjjm=VULeCR%i7o|xu5p~Sp7i<7SVug^OL%`^Vfp>JLJIi_I^-M!O;J$7vCkyQ zIkx#Z8bQH07E85B#2TVZ$c>nwIEC+$VN&3Gl4BZ-)3FRwM;M=6V&dHIDU&Teo4EbY z6V7`7E|}wkd6f`cXL7tJ$sb(tlIMA!B|c5^e-h8*Aorgc;rFG};TLJ*8;G_MJxsKp zsMGG#c#ek;w6r`E*xK8dO&Zz29%ZLeSF>YJR4c5O4^Pjd)z5A0Tc*IU&w z_UHGo9rp->H-bgrffpt3LkkT4%LMN~JHAx#KDFb^1h1bR_X=KjJ6a8@^IBm0JL+dc>;Y zyUHr{K-9tagdJZ4d69q>l68-GvUskoLXUt`Oz$u|UM>73alXaowedVI5g!%i5wnK( zp2)2%mM}Um%?CSJR6Sd!XWiTZ{X{=f6$j3+Dmz4cIQ<*X(CR>Mxwn3BXJB zf3b1BBmJxtt$e|91B>|im6WequwIv?pM~r7JJRRh3CY6O`?9id|J(=MRbqWtzyMal zJWSC(Bp{%Tsz=?*gw23tg%MwFFY6Bi};Bzf{!kO{{V1o z*ST+h3V47!VWGbP{0a9{g6~rnT}2!`@j0cR5pff6oVRY8HyN9Q(M9<9VINjI&c%;N zf0Xt!zb*a-IO;p+=N{>wq9088HUQt4k1dsFs(^*V{yY6t10Dc7XW0RGDL-w1Z*(dH z|DJ5O#61~K8yO?p->-zle1=Ig5?UB@OqkJ%q_lx}s!xw=5hIn(XnJ;7gj0#3c+`kS zlsyf58%ir-o-3y5>9jtA&%pGE=uhj3s20g45+h(@;WW@RY^vrH+I&7j*EFr|P_Q$k zg}PcbNOIZrBSLF^xhvS&-eQ-?yc$4|>(oLjc~Dyq3GG0~!RBCxcCfAO#ZZsd6Kw7X zp;wuW3um&j=bxB$!(?Am6KCsWjxHL}4IP}=6M*wdyC?}bk4Q6T3eYJ=uV!`+eA`of zS|pRw2K8hF(}3*<5Q;mZUPlqobz`SP<)fHJ;$+3pCmi( zKkG3)4sv|sFwgNW(>k(u_Mc#J0xCGCY|n8q( zhI2wcd&M}L<8AnbwiW04=Rj(;=XjjyF`G61mYRj>cY(Fq4^c;$a(~$k^Gv_*u;<@z zrp-LDEqtpPm!w%Sv`B|tS z#_e-_=O;s_KDK2oX524CofU*S+i!Q^Y;UE!_Eyy -#include -#include -#include -#include -#include -#include -#include - -const int MAX_ENV_SIZE = 1024*1024; -const int MAX_ENV_VARS = 1024; - -int main (int argc, char* argv[]) { - if (argc < 3) { - printf("Syntax: %s [args...]\n", argv[0]); - exit(1); - } - int fd = open(argv[1], O_RDONLY); - if (-1 == fd) { - perror("open"); - exit(1); - } - char env[MAX_ENV_SIZE+2]; - int env_size = read(fd, env, MAX_ENV_SIZE); - if (-1 == env_size) { - perror("read"); - exit(1); - } - if (MAX_ENV_SIZE == env_size) { - printf("WARNING: environment bigger than %d bytes. It has been truncated.\n", MAX_ENV_SIZE); - } - char* envp[MAX_ENV_VARS]; - int i; - char *c; - for (i=0, c=env; i Date: Wed, 15 Jul 2026 22:51:05 +0530 Subject: [PATCH 002/149] fix(auth): remove host SSH container access Signed-off-by: vibhatsu --- cmd/beast/commands.go | 2 - cmd/beast/disable_user_ssh.go | 16 ---- core/auth/ssh.go | 110 --------------------------- core/database/challenges.go | 88 ---------------------- core/database/user.go | 135 ---------------------------------- templates/login.go | 47 ------------ 6 files changed, 398 deletions(-) delete mode 100644 cmd/beast/disable_user_ssh.go delete mode 100644 core/auth/ssh.go delete mode 100644 templates/login.go diff --git a/cmd/beast/commands.go b/cmd/beast/commands.go index 6ca5467d..c581b6d7 100644 --- a/cmd/beast/commands.go +++ b/cmd/beast/commands.go @@ -113,7 +113,6 @@ 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) @@ -127,7 +126,6 @@ func init() { rootCmd.AddCommand(healthProbeCmd) rootCmd.AddCommand(verifyCmd) rootCmd.AddCommand(challengeCmd) - rootCmd.AddCommand(disableUserSSH) rootCmd.AddCommand(cmdRef) rootCmd.AddCommand(generateTemplateCmd) rootCmd.AddCommand(challDetailsCmd) diff --git a/cmd/beast/disable_user_ssh.go b/cmd/beast/disable_user_ssh.go deleted file mode 100644 index 5ffc18b2..00000000 --- a/cmd/beast/disable_user_ssh.go +++ /dev/null @@ -1,16 +0,0 @@ -package main - -import ( - "github.com/sdslabs/beastv4/core/auth" - "github.com/sdslabs/beastv4/core/config" - "github.com/spf13/cobra" -) - -var disableUserSSH = &cobra.Command{ - Use: "disable-author-ssh", - Short: "Disables current authors to ssh into the containers", - Run: func(cmd *cobra.Command, args []string) { - config.InitConfig() - auth.DisableUserSSH() - }, -} diff --git a/core/auth/ssh.go b/core/auth/ssh.go deleted file mode 100644 index b79d09c9..00000000 --- a/core/auth/ssh.go +++ /dev/null @@ -1,110 +0,0 @@ -package auth - -import ( - "bytes" - "crypto/rsa" - "crypto/sha256" - "crypto/x509" - "encoding/pem" - "errors" - "fmt" - "html/template" - "io/ioutil" - "path/filepath" - - "github.com/sdslabs/beastv4/core" - "github.com/sdslabs/beastv4/core/database" - "github.com/sdslabs/beastv4/templates" - "github.com/sdslabs/beastv4/utils" - log "github.com/sirupsen/logrus" - "golang.org/x/crypto/ssh" -) - -// This function takes in the path to the authorized keys file and parses it -// producing a map with ssh-public key as key and options corresponding to that -// as the value to corresponding key in the map. -func ParseAuthorizedKeysFile(filePath string) (map[string][]string, error) { - authorizedKeysMap := map[string][]string{} - err := utils.ValidateFileExists(filePath) - if err != nil { - log.Error("Error while validating authorized_keys file path") - return authorizedKeysMap, err - } - - authorizedKeysBytes, err := ioutil.ReadFile(filePath) - if err != nil { - eMsg := fmt.Errorf("Failed to load authorized_keys, err: %v", err) - log.Errorf("Error : %s", eMsg) - return authorizedKeysMap, eMsg - } - - var eMsg error - - for len(authorizedKeysBytes) > 0 { - pubKey, _, options, rest, err := ssh.ParseAuthorizedKey(authorizedKeysBytes) - if err != nil { - eMsg = fmt.Errorf("Error while parsing authorized_keys file : %s", err) - log.Error(eMsg.Error()) - break - } - - authorizedKeysMap[string(pubKey.Marshal())] = options - authorizedKeysBytes = rest - } - - return authorizedKeysMap, eMsg -} - -// This function parses ssh Private Key -func ParsePrivateKey(keyFile string) (*rsa.PrivateKey, error) { - keyString, err := ioutil.ReadFile(keyFile) - if err != nil { - return nil, err - } - - block, _ := pem.Decode(keyString) - if block == nil { - return nil, errors.New("Unable to decode") - } - key, err := x509.ParsePKCS1PrivateKey(block.Bytes) - if err != nil { - return nil, err - } - - return key, nil -} - -// This function disables the SSH based container access to all the current users -func DisableUserSSH() { - users, err := database.QueryAllUsers() - if err != nil { - log.Errorf("DB ERROR : %v", err) - return - } - for _, user := range users { - SHA256 := sha256.New() - SHA256.Write([]byte(user.Email)) - scriptPath := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_SCRIPTS_DIR, fmt.Sprintf("%x", SHA256.Sum(nil))) - - data := database.ScriptFile{ - User: user.Name, - } - - var script bytes.Buffer - scriptTemplate, err := template.New("script").Parse(templates.SSH_RESTRICT_LOGIN_SCRIPT_TEMPLATE) - if err != nil { - log.Errorf("Error while parsing script template :: %v", err) - continue - } - - if err = scriptTemplate.Execute(&script, data); err != nil { - log.Errorf("Error while executing script template :: %v", err) - continue - } - - if err = ioutil.WriteFile(scriptPath, script.Bytes(), 0755); err != nil { - log.Errorf("Error while writing to the script file :: %v", err) - continue - } - } -} diff --git a/core/database/challenges.go b/core/database/challenges.go index 8c6bd547..31dd1d95 100644 --- a/core/database/challenges.go +++ b/core/database/challenges.go @@ -1,13 +1,8 @@ package database import ( - "bytes" - "crypto/sha256" "errors" "fmt" - "html/template" - "io/ioutil" - "path/filepath" "sort" "strings" "time" @@ -16,7 +11,6 @@ import ( "github.com/sdslabs/beastv4/core" "github.com/sdslabs/beastv4/core/config" - tools "github.com/sdslabs/beastv4/templates" log "github.com/sirupsen/logrus" "gorm.io/gorm" @@ -554,88 +548,6 @@ func SaveFlagSubmission(user_challenges *UserChallenges) error { return tx.Commit().Error } -// hook after update of challenge -func (challenge *Challenge) AfterUpdate(tx *gorm.DB) error { - iFace, _ := tx.InstanceGet("gorm:update_attrs") - if iFace == nil { - return nil - } - updatedAttr := iFace.(map[string]interface{}) - if _, ok := updatedAttr["container_id"]; ok { - var users []*User - Db.Model(challenge).Association("Users") - go updateScripts(users) - } - return nil -} - -// hook after create of challenge -func (challenge *Challenge) AfterCreate(tx *gorm.DB) error { - var users []*User - Db.Model(challenge).Association("Users") - go updateScripts(users) - - return nil -} - -// hook after deleting the challenge -func (challenge *Challenge) AfterDelete(tx *gorm.DB) error { - var users []*User - Db.Model(challenge).Association("Users") - go updateScripts(users) - return nil -} - -type ScriptFile struct { - User string - Challenges map[string]string -} - -// updates users' script -func updateScripts(users []*User) { - for _, user := range users { - go updateScript(user) - } -} - -// updates user script -func updateScript(user *User) error { - - time.Sleep(3 * time.Second) - - SHA256 := sha256.New() - SHA256.Write([]byte(user.Email)) - scriptPath := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_SCRIPTS_DIR, fmt.Sprintf("%x", SHA256.Sum(nil))) - challs, err := GetRelatedChallenges(user) - if err != nil { - return fmt.Errorf("error while getting related challenges : %v", err) - } - - mapOfChall := make(map[string]string) - - for _, chall := range challs { - mapOfChall[chall.Name] = chall.ContainerId - } - - data := ScriptFile{ - User: user.Name, - Challenges: mapOfChall, - } - - var script bytes.Buffer - scriptTemplate, err := template.New("script").Parse(tools.SSH_LOGIN_SCRIPT_TEMPLATE) - if err != nil { - return fmt.Errorf("error while parsing script template :: %s", err) - } - - err = scriptTemplate.Execute(&script, data) - if err != nil { - return fmt.Errorf("error while executing script template :: %s", err) - } - - return ioutil.WriteFile(scriptPath, script.Bytes(), 0755) -} - // Create a new entry in the DynamicFlag table func CreateDynamicFlagEntry(dynamicFlag *DynamicFlag) error { diff --git a/core/database/user.go b/core/database/user.go index 476a249f..7ade59a8 100644 --- a/core/database/user.go +++ b/core/database/user.go @@ -1,22 +1,12 @@ package database import ( - "bytes" - "crypto/sha256" "errors" "fmt" - "html/template" - "io/ioutil" - "os" - "path/filepath" - "regexp" - "strconv" "time" "github.com/sdslabs/beastv4/core" - "github.com/sdslabs/beastv4/core/config" "github.com/sdslabs/beastv4/pkg/auth" - tools "github.com/sdslabs/beastv4/templates" log "github.com/sirupsen/logrus" "gorm.io/gorm" ) @@ -265,131 +255,6 @@ func CheckPreviousSubmissions(userId uint, challId uint) (bool, error) { return (count >= 1), tx.Error } -// hook after create -func (user *User) AfterCreate(tx *gorm.DB) error { - if user.SshKey == "" { - return nil - } - if err := addToAuthorizedKeys(user); err != nil { - return fmt.Errorf("Error while adding userized_keys : %s", err) - } - return nil -} - -// hook after update -func (user *User) AfterUpdate(tx *gorm.DB) error { - iFace, _ := tx.InstanceGet("gorm:update_attrs") - if iFace == nil { - return nil - } - updatedAttr := iFace.(map[string]interface{}) - if _, ok := updatedAttr["ssh_key"]; ok { - err := deleteFromAuthorizedKeys(user) - if err != nil { - return fmt.Errorf("Error while deleting from userized_keys : %s", err) - } - if user.SshKey == "" { - return nil - } - err = addToAuthorizedKeys(user) - if err != nil { - return fmt.Errorf("Error while adding userized_keys : %s", err) - } - err = updateScript(user) - if err != nil { - return fmt.Errorf("Error while updating script : %s", err) - } - } - return nil -} - -// Updating data in same transaction -func (user *User) AfterDelete(tx *gorm.DB) error { - err := deleteFromAuthorizedKeys(user) - return err -} - -type AuthorizedKeyTemplate struct { - UserID string - Command string - PubKey string -} - -func generateContentAuthorizedKeyFile(user *User) ([]byte, error) { - SHA256 := sha256.New() - SHA256.Write([]byte(user.Email)) - scriptPath := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_SCRIPTS_DIR, fmt.Sprintf("%x", SHA256.Sum(nil))) - - data := AuthorizedKeyTemplate{ - UserID: strconv.Itoa(int(user.Model.ID)), - Command: scriptPath, - PubKey: user.SshKey, - } - - var authKey bytes.Buffer - authKeyTemplate, err := template.New("authKey").Parse(tools.AUTHORIZED_KEY_TEMPLATE) - if err != nil { - return []byte(""), fmt.Errorf("Error while parsing script template :: %s", err) - } - - err = authKeyTemplate.Execute(&authKey, data) - if err != nil { - return []byte(""), fmt.Errorf("Error while executing script template :: %s", err) - } - - return authKey.Bytes(), nil -} - -// adds to authorized keys -func addToAuthorizedKeys(user *User) error { - if config.Cfg == nil { - log.Warn("No config initialized, skipping add to authorized keys hook") - return nil - } - - f, err := os.OpenFile(config.Cfg.AuthorizedKeysFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) - if err != nil { - return fmt.Errorf("Error while opening userized keys file : %s", err) - } - defer f.Close() - - authBytes, err := generateContentAuthorizedKeyFile(user) - if err != nil { - return err - } - - authBytes = bytes.Replace(authBytes, []byte("+"), []byte("+"), -1) - - if _, err := f.Write(authBytes); err != nil { - return fmt.Errorf("Error while appending key to userized keys file : %s", err) - } - return nil -} - -func deleteFromAuthorizedKeys(user *User) error { - - if config.Cfg == nil { - log.Warn("Config is not initialized, skipping delete from auth keys hook") - return nil - } - - keys, err := ioutil.ReadFile(config.Cfg.AuthorizedKeysFile) - if err != nil { - return fmt.Errorf("Error while reading auth file : %s", err) - } - - regex := "(?m)[\r\n]+^.*\"SSH_USER=" + strconv.Itoa(int(user.ID)) + "\".*$" - - re := regexp.MustCompile(regex) - newKeys := []byte(re.ReplaceAllString(string(keys), "")) - - err = ioutil.WriteFile(config.Cfg.AuthorizedKeysFile, newKeys, 0644) - if err != nil { - return fmt.Errorf("Error while writing to auth file : %s", err) - } - return nil -} - func QueryTopUsersByScore(limit int) ([]User, error) { var users []User diff --git a/templates/login.go b/templates/login.go deleted file mode 100644 index 2c20f8b1..00000000 --- a/templates/login.go +++ /dev/null @@ -1,47 +0,0 @@ -package templates - -// This template is to be inserted in the authorized_keys file which contains -// authorized ssh keys to login to the challenge container -var AUTHORIZED_KEY_TEMPLATE string = ` -command="{{.Command}}",environment="SSH_USER={{.UserID}}",no-agent-forwarding,no-port-forwarding,no-X11-forwarding` + - ` {{.PubKey}} ` - -// This script will be run for each login, and command will be forced -// to enter the container on the basis of the command used by the -// user while logging in through ssh. -var SSH_LOGIN_SCRIPT_TEMPLATE string = ` -#!/bin/bash -# -# This is an automatically generated shell script, don't edit this -# this wraps the forced command for a ssh session. -# $SSH_ORIGINAL_COMMAND recieves the original command sent by the user. -# This script is generate for user : {{.Author}} - -case "$SSH_ORIGINAL_COMMAND" in - - {{range $name, $containerId := .Challenges}} - "{{ $name }}") - exec docker_enter {{ $containerId }} - ;; - {{end}} - - *) - echo "Access denied...." - exit 1 - ;; - -esac -` - -// This script will be run for each login, when the ssh is disabled for the -// author while logging in through ssh. -var SSH_RESTRICT_LOGIN_SCRIPT_TEMPLATE string = ` -#!/bin/bash -# -# This is an automatically generated shell script, don't edit this -# this disables the author to enter the docker container through ssh. -# This script is generate for user : {{.Author}} - -echo "SSH access has been disabled for all the current authors. Contact the admin." -exit 1 -` From 4200483cef146bc672a459cdd678d599caf6be68 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Wed, 15 Jul 2026 22:52:21 +0530 Subject: [PATCH 003/149] fix(auth): remove obsolete SSH key inputs Signed-off-by: vibhatsu --- api/auth.go | 4 ---- cmd/beast/commands.go | 3 --- cmd/beast/create_admin_author.go | 12 ++---------- cmd/beast/init.go | 10 +--------- core/config/challenge.go | 13 ++++--------- core/database/user.go | 9 ++++----- core/manager/utils.go | 2 -- core/utils/command_utils.go | 23 +---------------------- templates/templates.go | 1 - 9 files changed, 12 insertions(+), 65 deletions(-) diff --git a/api/auth.go b/api/auth.go index 2d408eda..a50194d0 100644 --- a/api/auth.go +++ b/api/auth.go @@ -197,7 +197,6 @@ func login(c *gin.Context) { // @Param username formData string true "Username" // @Param password formData string true "Password" // @Param email formData string true "User's email id" -// @Param ssh-key formData string false "User's ssh-key" // @Success 200 {object} api.HTTPPlainResp // @Failure 400 {object} api.HTTPPlainResp // @Failure 406 {object} api.HTTPPlainResp @@ -207,13 +206,11 @@ func register(c *gin.Context) { username := c.PostForm("username") password := c.PostForm("password") email := c.PostForm("email") - sshKey := c.PostForm("ssh-key") name = strings.TrimSpace(name) username = strings.TrimSpace(strings.ToLower(username)) password = strings.TrimSpace(password) email = strings.TrimSpace(strings.ToLower(email)) - sshKey = strings.TrimSpace(sshKey) if username == "" || password == "" || email == "" { @@ -234,7 +231,6 @@ func register(c *gin.Context) { Name: name, AuthModel: auth.CreateModel(username, password, core.USER_ROLES["contestant"]), Email: email, - SshKey: sshKey, } // skip otp verif if -n flag is enabled diff --git a/cmd/beast/commands.go b/cmd/beast/commands.go index c581b6d7..3cfb62dc 100644 --- a/cmd/beast/commands.go +++ b/cmd/beast/commands.go @@ -19,7 +19,6 @@ var ( Username string Email string Password string - PublicKeyPath string SkipAuthorization bool AllChalls bool AutoDeploy bool @@ -92,13 +91,11 @@ func init() { createAuthorCmd.PersistentFlags().StringVarP(&Username, "username", "", "", "Username of the new author") createAuthorCmd.PersistentFlags().StringVarP(&Password, "password", "", "", "Password of the author") createAuthorCmd.PersistentFlags().StringVarP(&Email, "email", "", "", "Email of the new author") - createAuthorCmd.PersistentFlags().StringVarP(&PublicKeyPath, "publickey", "", "", "Public key file representing new author") createAdminCmd.PersistentFlags().StringVarP(&Name, "name", "", "", "Name of the new admin") createAdminCmd.PersistentFlags().StringVarP(&Username, "username", "", "", "Username of the new admin") createAdminCmd.PersistentFlags().StringVarP(&Password, "password", "", "", "Password of the admin") createAdminCmd.PersistentFlags().StringVarP(&Email, "email", "", "", "Email of the new admin") - createAdminCmd.PersistentFlags().StringVarP(&PublicKeyPath, "publickey", "", "", "Public key file representing new admin") challengeCmd.PersistentFlags().BoolVarP(&AllChalls, "all", "a", false, "Performs action to all challs") challengeCmd.PersistentFlags().StringVarP(&Tag, "tag", "t", "", "Performs action to the tag provided") diff --git a/cmd/beast/create_admin_author.go b/cmd/beast/create_admin_author.go index 453e5df6..d7192b65 100644 --- a/cmd/beast/create_admin_author.go +++ b/cmd/beast/create_admin_author.go @@ -36,10 +36,6 @@ var createAuthorCmd = &cobra.Command{ os.Exit(1) } - if PublicKeyPath == "" { - fmt.Printf("Public Key Path not provided") - } - if Password == "" { fmt.Printf("Password not provided") os.Exit(1) @@ -48,7 +44,7 @@ var createAuthorCmd = &cobra.Command{ Run: func(cmd *cobra.Command, args []string) { createAuthorAdminPrereq() - utils.CreateAdminOrAuthor(Name, Username, Email, PublicKeyPath, Password, "author") + utils.CreateAdminOrAuthor(Name, Username, Email, Password, "author") }, } @@ -71,10 +67,6 @@ var createAdminCmd = &cobra.Command{ os.Exit(1) } - if PublicKeyPath == "" { - fmt.Printf("Public Key Path not provided") - } - if Password == "" { fmt.Printf("Password not provided") os.Exit(1) @@ -83,6 +75,6 @@ var createAdminCmd = &cobra.Command{ Run: func(cmd *cobra.Command, args []string) { createAuthorAdminPrereq() - utils.CreateAdminOrAuthor(Name, Username, Email, PublicKeyPath, Password, "admin") + utils.CreateAdminOrAuthor(Name, Username, Email, Password, "admin") }, } diff --git a/cmd/beast/init.go b/cmd/beast/init.go index 9a62b0a3..ab5dacff 100644 --- a/cmd/beast/init.go +++ b/cmd/beast/init.go @@ -284,18 +284,10 @@ func initAdmin() error { return errors.New("admin password is required") } - publicKeyPath, err := utils.PromptPublicKeyFile() - if err != nil { - return err - } - if publicKeyPath == "" { - log.Warnln("No public key provided... proceeding without it") - } - database.Init() createAuthorAdminPrereq() - coreUtils.CreateAdminOrAuthor(name, username, email, publicKeyPath, password, "admin") + coreUtils.CreateAdminOrAuthor(name, username, email, password, "admin") } return nil diff --git a/core/config/challenge.go b/core/config/challenge.go index 47da75cb..b04d7d6a 100644 --- a/core/config/challenge.go +++ b/core/config/challenge.go @@ -38,7 +38,6 @@ func (config *BeastChallengeConfig) PopulateDefaultValues() { func (Author *Author) PopulateAuthor() { Author.Name = "AuthorName" Author.Email = "AuthorMail" - Author.SSHKey = "AuthorPubKey" } func (Metadata *ChallengeMetadata) PopulateChallengeMetadata() { @@ -480,8 +479,6 @@ func (config *ChallengeEnv) ExtractPortsCompose(challdir string) error { // // - Name - Name of the author of the challenge // - Email - Email of the author -// - SSHKey - Public SSH key for the challenge author, to give the access -// to the challenge container. // // ```toml // # Optional fields @@ -489,17 +486,15 @@ func (config *ChallengeEnv) ExtractPortsCompose(challdir string) error { // // # Required Fields // email = "" -// ssh_key = "" # Public ssh Key of the author. // ``` type Author struct { - Name string `toml:"name"` - Email string `toml:"email"` - SSHKey string `toml:"ssh_key"` + Name string `toml:"name"` + Email string `toml:"email"` } func (config *Author) ValidateRequiredFields() error { - if config.Email == "" || config.SSHKey == "" { - return errors.New("Challenge `email` and `ssh_key` are required") + if config.Email == "" { + return errors.New("challenge author email is required") } if config.Name == "" { diff --git a/core/database/user.go b/core/database/user.go index 7ade59a8..5fa01338 100644 --- a/core/database/user.go +++ b/core/database/user.go @@ -18,11 +18,10 @@ type User struct { Challenges []*Challenge `gorm:"many2many:user_challenges;"` Name string `gorm:"not null"` Email string `gorm:"non null;unique"` - SshKey string - Status uint `gorm:"not null;default:0"` // 0 for unbanned, 1 for banned - Score uint `gorm:"default:0"` - FrozenScore uint `gorm:"default:0"` - Hints []*Hint `gorm:"many2many:user_hints;references:HintID;joinReferences:HintID"` + Status uint `gorm:"not null;default:0"` // 0 for unbanned, 1 for banned + Score uint `gorm:"default:0"` + FrozenScore uint `gorm:"default:0"` + Hints []*Hint `gorm:"many2many:user_hints;references:HintID;joinReferences:HintID"` } // Queries all the users entries where the column represented by key diff --git a/core/manager/utils.go b/core/manager/utils.go index 497b8a14..f4494c03 100644 --- a/core/manager/utils.go +++ b/core/manager/utils.go @@ -457,7 +457,6 @@ func UpdateOrCreateChallengeDbEntry(challEntry *database.Challenge, config cfg.B Name: config.Author.Name, AuthModel: auth.CreateModel(config.Author.Email, defaultauthorpassword, core.USER_ROLES["author"]), Email: config.Author.Email, - SshKey: config.Author.SSHKey, } err = database.CreateUserEntry(&newUser) if err != nil { @@ -467,7 +466,6 @@ func UpdateOrCreateChallengeDbEntry(challEntry *database.Challenge, config cfg.B // return nil } else { if userEntry.Email != config.Author.Email && - (userEntry.SshKey != config.Author.SSHKey || config.Author.SSHKey == "") && (userEntry.Name != config.Author.Name || config.Author.Name == "") && userEntry.Role != core.USER_ROLES["author"] { return fmt.Errorf("ERROR, author details for %s did not match with the ones in database", userEntry.Email) diff --git a/core/utils/command_utils.go b/core/utils/command_utils.go index 3327d800..e6479536 100644 --- a/core/utils/command_utils.go +++ b/core/utils/command_utils.go @@ -2,39 +2,18 @@ package utils import ( "fmt" - "os" "github.com/sdslabs/beastv4/core" "github.com/sdslabs/beastv4/core/database" "github.com/sdslabs/beastv4/pkg/auth" - "github.com/sdslabs/beastv4/utils" log "github.com/sirupsen/logrus" ) -func CreateAdminOrAuthor(name string, username string, email string, publicKeyPath string, password string, role string) { - var sshKey []byte - if publicKeyPath != "" { - err := utils.ValidateFileExists(publicKeyPath) - if err != nil { - log.Errorf("Error while checking validity of file(%v): %v : ", publicKeyPath, err) - return - } - - sshKey, err = os.ReadFile(publicKeyPath) - if err != nil { - log.Errorf("Error while reading file: %v", err) - return - } - - } else { - log.Warn("SSH Key for author is not provided") - } - +func CreateAdminOrAuthor(name string, username string, email string, password string, role string) { userEntry := database.User{ Name: name, AuthModel: auth.CreateModel(username, password, core.USER_ROLES[role]), Email: email, - SshKey: string(sshKey), } err := database.CreateUserEntry(&userEntry) if err != nil { diff --git a/templates/templates.go b/templates/templates.go index 3e15471c..35faeaf4 100644 --- a/templates/templates.go +++ b/templates/templates.go @@ -4,7 +4,6 @@ var CHALLENGE_CONFIG_FILE_TEMPLATE string = `# This a sample challenge configura [author] name = {{.Author.Name}} # Required: Name of the challenge creator email = {{.Author.Email}} # Required: Email for contact -ssh_key = {{.Author.SSHKey}} # Required: Public SSH key for the challenge author [challenge.metadata] name = {{.Challenge.Metadata.Name}} # Required: Name of the challenge, should be same as directory. From b776af830a00ea1cd47806f6565eeeeca2573ddc Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Wed, 15 Jul 2026 22:53:18 +0530 Subject: [PATCH 004/149] fix(config): remove legacy author SSH settings Signed-off-by: vibhatsu --- cmd/beast/config.go | 64 ++----------------------------------------- cmd/beast/init.go | 1 - core/config/config.go | 40 --------------------------- core/constants.go | 7 ++--- 4 files changed, 4 insertions(+), 108 deletions(-) diff --git a/cmd/beast/config.go b/cmd/beast/config.go index 2b82f59b..a19e9d6c 100644 --- a/cmd/beast/config.go +++ b/cmd/beast/config.go @@ -18,8 +18,7 @@ import ( ) var ( - MINIMUM_MEMORY_LIMIT int64 = (1 << 23) /* a little over 6MB */ - AUTHORIZED_KEYS_FILE string = filepath.Join(core.BEAST_GLOBAL_DIR, core.DEFAULT_AUTH_KEYS_FILE) + MINIMUM_MEMORY_LIMIT int64 = (1 << 23) /* a little over 6MB */ BEAST_GLOBAL_CONFIG string = filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_CONFIG_FILE_NAME) @@ -27,61 +26,6 @@ var ( BEAST_EXAMPLE_CONFIG string = filepath.Join(BEAST_EXAMPLE_DIRECTORY, core.BEAST_EX_CONFIG_FILE_NAME) ) -func copySSHKey() error { - location, err := utils.PromptPublicKeyFile() - if err != nil { - return err - } - - if location == "" { - log.Warnln("No public key file selected... aborting") - return nil - } - - publicKeyFile, err := os.Open(location) - if err != nil { - return err - } - defer publicKeyFile.Close() - - authorizedKeyFile, err := os.OpenFile(AUTHORIZED_KEYS_FILE, os.O_APPEND|os.O_WRONLY, 0600) - if err != nil { - return err - } - defer authorizedKeyFile.Close() - - _, err = io.Copy(authorizedKeyFile, publicKeyFile) - if err != nil { - return err - } - - _, err = authorizedKeyFile.WriteString("\n\n") - if err != nil { - return err - } - - log.Infoln(fmt.Sprintf("Added ssh key at %s", location)) - return nil -} - -func initAuthorizedKeysFile() error { - log.Infoln("Defaulting Authorized keys file:", AUTHORIZED_KEYS_FILE, "... can be changed later") - - err := os.WriteFile(AUTHORIZED_KEYS_FILE, []byte{}, 0666) - if err != nil { - return err - } - - for utils.PromptBinary("Would you like to add a public key to the authorized_keys file?") { - err = copySSHKey() - if err != nil { - return err - } - } - - return nil -} - func downloadExampleBeastConfig() error { log.Println("Downloading example config file from GitHub...") @@ -296,10 +240,6 @@ func tryCopyExampleConfig() error { } func initBeastConfig() error { - if err := initAuthorizedKeysFile(); err != nil { - return err - } - if _, err := os.Stat(BEAST_GLOBAL_CONFIG); os.IsNotExist(err) { return tryCopyExampleConfig() } @@ -311,7 +251,7 @@ func initBeastConfig() error { var configCmd = &cobra.Command{ Use: "config", Short: "Run interactive beast configuration setup", - Long: "Creates the default Authorized Keys File and Global Beast Config file while prompting the user interactively whenever needed.", + Long: "Creates the global Beast config file while prompting the user interactively whenever needed.", Run: func(cmd *cobra.Command, args []string) { err := initBeastConfig() diff --git a/cmd/beast/init.go b/cmd/beast/init.go index ab5dacff..f4b0fc0c 100644 --- a/cmd/beast/init.go +++ b/cmd/beast/init.go @@ -39,7 +39,6 @@ func initDirectories() error { filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_CACHE_DIR), filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_REMOTES_DIR), filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_UPLOADS_DIR), - filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_SCRIPTS_DIR), filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_SECRETS_DIR), filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_STAGING_DIR), filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_ASSETS_DIR, core.BEAST_LOGO_DIR), diff --git a/core/config/config.go b/core/config/config.go index 98255850..a4c14460 100644 --- a/core/config/config.go +++ b/core/config/config.go @@ -23,16 +23,6 @@ import ( // # An example of a config file // // ```toml -// # Authorized key file used by ssh daemon running on the host -// # This is used for forwarding ssh connection to docker containers, the -// # access to a container is only given to the author, maintainers of challenge and admin. -// authorized_keys_file = "/home/fristonio/.beast/beast_authorized_keys" -// -// # Directory which will contain all the autogenerated scripts by beast -// # These scripts are the heart to above authorized keys file. Each entry in authorized -// # keys file as a corresponding script which is executed during an SSH attempt. -// scripts_dir = "/home/fristonio/.beast/scripts" -// // # Base OS image that beast allows the challenges to use. // allowed_base_images = ["ubuntu:18.04", "ubuntu:16.04", "debian:jessie"] // @@ -112,8 +102,6 @@ import ( // sslmode = "prefer" // ``` type BeastConfig struct { - AuthorizedKeysFile string `toml:"authorized_keys_file"` - BeastScriptsDir string `toml:"scripts_dir"` AllowedBaseImages []string `toml:"allowed_base_images"` AvailableServers map[string]AvailableServer `toml:"available_servers"` GitRemotes []GitRemote `toml:"remote"` @@ -184,34 +172,6 @@ func ValidatePortRange(portRange string) error { func (config *BeastConfig) ValidateConfig() error { log.Debug("Validating BeastConfig structure") - if config.AuthorizedKeysFile != "" { - err := utils.CreateFileIfNotExist(config.AuthorizedKeysFile) - if err != nil { - log.Errorf("Error while creating authorized_keys file : %s", config.AuthorizedKeysFile) - } - - config.AuthorizedKeysFile, err = filepath.Abs(config.AuthorizedKeysFile) - if err != nil { - return fmt.Errorf("error while getting absolute path : %s", err) - } - } else { - defaultAuthKeyFile := filepath.Join(os.Getenv("HOME"), core.DEFAULT_AUTH_KEYS_FILE) - log.Warnf("No authorized_keys file path provided, using default : %s", defaultAuthKeyFile) - config.AuthorizedKeysFile = defaultAuthKeyFile - } - - if config.BeastScriptsDir == "" { - defaultBeastScriptDir := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_SCRIPTS_DIR) - log.Warnf("no scripts directory provided for beast, using default : %s", defaultBeastScriptDir) - config.BeastScriptsDir = defaultBeastScriptDir - } else { - err := utils.CreateIfNotExistDir(config.BeastScriptsDir) - if err != nil { - log.Error("Error while creating beast scripts directory") - return err - } - } - err := config.PsqlConf.ValidatePsqlConfig() if err != nil { return fmt.Errorf("error while validating db config : %s", err) diff --git a/core/constants.go b/core/constants.go index a46cf279..ff99d822 100644 --- a/core/constants.go +++ b/core/constants.go @@ -8,9 +8,8 @@ import ( var ( // BEAST_GLOBAL_DIR should always be used only on local deployment - BEAST_GLOBAL_DIR = filepath.Join(os.Getenv("HOME"), ".beast") - AUTHORIZED_KEYS_FILE = filepath.Join(os.Getenv("HOME"), ".ssh", "authorized_keys") - BEAST_TEMP_DIR = filepath.Join(os.TempDir(), "beast") + BEAST_GLOBAL_DIR = filepath.Join(os.Getenv("HOME"), ".beast") + BEAST_TEMP_DIR = filepath.Join(os.TempDir(), "beast") ) const ( //names @@ -47,9 +46,7 @@ const ( //names const ( //paths BEAST_DOCKER_CHALLENGE_DIR string = "/challenge" BEAST_CHALLENGE_LOGS_DIR string = "logs" - DEFAULT_AUTH_KEYS_FILE string = "beast_authorized_keys" BEAST_STAGING_DIR string = "staging" - BEAST_SCRIPTS_DIR string = "scripts" BEAST_REMOTES_DIR string = "remote" BEAST_STAGING_AREA_MOUNT_POINT string = "/beast" BEAST_UPLOADS_DIR string = "uploads" From 5b5a7f43b9f684a2d5a9b6a2a9a4468d31db0467 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Wed, 15 Jul 2026 22:54:41 +0530 Subject: [PATCH 005/149] fix(config): reject unknown TOML keys Signed-off-by: vibhatsu --- core/config/config.go | 3 +-- core/config/utils.go | 30 ++++++++++++++++++++++++++++++ core/config/utils_test.go | 37 +++++++++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 core/config/utils_test.go diff --git a/core/config/config.go b/core/config/config.go index a4c14460..7e2fbd1e 100644 --- a/core/config/config.go +++ b/core/config/config.go @@ -489,8 +489,7 @@ func LoadBeastConfig(configPath string) (BeastConfig, error) { return config, err } - _, err = toml.DecodeFile(configPath, &config) - if err != nil { + if err = decodeTOMLFileStrict(configPath, &config); err != nil { return config, err } diff --git a/core/config/utils.go b/core/config/utils.go index 94157e67..80dcfab5 100644 --- a/core/config/utils.go +++ b/core/config/utils.go @@ -1,11 +1,41 @@ package config import ( + "fmt" + "sort" "strings" + "github.com/BurntSushi/toml" "github.com/sdslabs/beastv4/core" ) +func decodeTOMLFileStrict(path string, target interface{}) error { + metadata, err := toml.DecodeFile(path, target) + if err != nil { + return err + } + + undecoded := metadata.Undecoded() + if len(undecoded) == 0 { + return nil + } + + keys := make([]string, 0, len(undecoded)) + for _, key := range undecoded { + keys = append(keys, key.String()) + } + sort.Strings(keys) + return fmt.Errorf("unknown configuration keys: %s", strings.Join(keys, ", ")) +} + +func LoadChallengeConfig(path string) (BeastChallengeConfig, error) { + var config BeastChallengeConfig + if err := decodeTOMLFileStrict(path, &config); err != nil { + return BeastChallengeConfig{}, err + } + return config, nil +} + func GetAvailableChallengeTypes() []string { types := core.AVAILABLE_CHALLENGE_TYPES diff --git a/core/config/utils_test.go b/core/config/utils_test.go new file mode 100644 index 00000000..0df42674 --- /dev/null +++ b/core/config/utils_test.go @@ -0,0 +1,37 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLoadChallengeConfigRejectsUnknownKeys(t *testing.T) { + path := filepath.Join(t.TempDir(), "beast.toml") + data := []byte("[author]\nemail = \"author@example.com\"\nunknown = true\n") + if err := os.WriteFile(path, data, 0600); err != nil { + t.Fatal(err) + } + + _, err := LoadChallengeConfig(path) + if err == nil || !strings.Contains(err.Error(), "author.unknown") { + t.Fatalf("expected unknown key error, got %v", err) + } +} + +func TestLoadChallengeConfigAcceptsKnownKeys(t *testing.T) { + path := filepath.Join(t.TempDir(), "beast.toml") + data := []byte("[author]\nemail = \"author@example.com\"\n") + if err := os.WriteFile(path, data, 0600); err != nil { + t.Fatal(err) + } + + config, err := LoadChallengeConfig(path) + if err != nil { + t.Fatal(err) + } + if config.Author.Email != "author@example.com" { + t.Fatalf("unexpected author email %q", config.Author.Email) + } +} From 401ab8418e16b29fcc421628efd9f5c6445e491f Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Wed, 15 Jul 2026 22:55:26 +0530 Subject: [PATCH 006/149] fix(config): enforce strict challenge decoding Signed-off-by: vibhatsu --- api/manage.go | 4 +--- core/manager/challenge.go | 5 ++--- core/manager/instance.go | 4 +--- core/manager/pipeline.go | 4 +--- core/manager/utils.go | 10 +++------- 5 files changed, 8 insertions(+), 19 deletions(-) diff --git a/api/manage.go b/api/manage.go index e40505ce..af58048b 100644 --- a/api/manage.go +++ b/api/manage.go @@ -8,7 +8,6 @@ import ( "strings" "time" - "github.com/BurntSushi/toml" "github.com/gin-gonic/gin" "github.com/sdslabs/beastv4/core" cfg "github.com/sdslabs/beastv4/core/config" @@ -505,8 +504,7 @@ func manageUploadHandler(c *gin.Context) { challengeName := filepath.Base(challengeUploadDirectory) configFile := filepath.Join(challengeUploadDirectory, core.CHALLENGE_CONFIG_FILE_NAME) - var config cfg.BeastChallengeConfig - _, err = toml.DecodeFile(configFile, &config) + config, err := cfg.LoadChallengeConfig(configFile) if err != nil { log.Errorf("Error while loading beast config for challenge %s : %s", challengeName, err) c.JSON(http.StatusBadRequest, HTTPErrorResp{ diff --git a/core/manager/challenge.go b/core/manager/challenge.go index 70680bab..c362e181 100644 --- a/core/manager/challenge.go +++ b/core/manager/challenge.go @@ -7,7 +7,6 @@ import ( "path/filepath" "strings" - "github.com/BurntSushi/toml" containerType "github.com/docker/docker/api/types" _ "github.com/jinzhu/gorm/dialects/sqlite" "github.com/sdslabs/beastv4/core" @@ -726,8 +725,8 @@ func undeployChallenge(challengeName string, purge bool) error { // and then remove the challenge from the staging directory. if purge { configFile := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_STAGING_DIR, challengeName, core.CHALLENGE_CONFIG_FILE_NAME) - var cfg config.BeastChallengeConfig - _, err = toml.DecodeFile(configFile, &cfg) + cfg, loadErr := config.LoadChallengeConfig(configFile) + err = loadErr if err != nil { return err } diff --git a/core/manager/instance.go b/core/manager/instance.go index 9e0cfdfe..56fb9cca 100644 --- a/core/manager/instance.go +++ b/core/manager/instance.go @@ -5,7 +5,6 @@ import ( "path/filepath" "time" - "github.com/BurntSushi/toml" "github.com/google/uuid" "github.com/sdslabs/beastv4/core" "github.com/sdslabs/beastv4/core/cache" @@ -42,8 +41,7 @@ func SpawnInstance(challengeName, userID, username string) (*cache.Instance, err challengeStagingDir := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_STAGING_DIR, challengeName) configFile := filepath.Join(challengeStagingDir, core.CHALLENGE_CONFIG_FILE_NAME) - var config cfg.BeastChallengeConfig - _, err = toml.DecodeFile(configFile, &config) + config, err := cfg.LoadChallengeConfig(configFile) if err != nil { return nil, fmt.Errorf("failed to load challenge config: %w", err) } diff --git a/core/manager/pipeline.go b/core/manager/pipeline.go index 08a257f8..1a9e366b 100644 --- a/core/manager/pipeline.go +++ b/core/manager/pipeline.go @@ -16,7 +16,6 @@ import ( "github.com/sdslabs/beastv4/pkg/remoteManager" "github.com/sdslabs/beastv4/utils" - "github.com/BurntSushi/toml" log "github.com/sirupsen/logrus" ) @@ -440,8 +439,7 @@ func bootstrapDeployPipeline(challengeDir string, skipStage bool, skipCommit boo challengeName := filepath.Base(challengeDir) configFile := filepath.Join(challengeDir, core.CHALLENGE_CONFIG_FILE_NAME) - var config cfg.BeastChallengeConfig - _, err := toml.DecodeFile(configFile, &config) + config, err := cfg.LoadChallengeConfig(configFile) if err != nil { log.Errorf("Error while loading beast config for challenge %s : %s", challengeName, err) return fmt.Errorf("CONFIG ERROR: %s : %s", challengeName, err) diff --git a/core/manager/utils.go b/core/manager/utils.go index f4494c03..ccf280ca 100644 --- a/core/manager/utils.go +++ b/core/manager/utils.go @@ -23,7 +23,6 @@ import ( tools "github.com/sdslabs/beastv4/templates" "github.com/sdslabs/beastv4/utils" - "github.com/BurntSushi/toml" log "github.com/sirupsen/logrus" ) @@ -74,8 +73,7 @@ func ValidateChallengeConfig(challengeDir string) error { return err } - var config cfg.BeastChallengeConfig - _, err = toml.DecodeFile(configFile, &config) + config, err := cfg.LoadChallengeConfig(configFile) if err != nil { return err } @@ -619,8 +617,7 @@ func UpdateOrCreateChallengeDbEntry(challEntry *database.Challenge, config cfg.B // Provides the Static Content Folder Name from the config func GetStaticContentDir(configFile, contextDir string) (string, error) { - var config cfg.BeastChallengeConfig - _, err := toml.DecodeFile(configFile, &config) + config, err := cfg.LoadChallengeConfig(configFile) if err != nil { return "", fmt.Errorf("error while decoding file : %s", configFile) } @@ -872,8 +869,7 @@ func UpdateChallenges(defaultauthorpassword string) { for _, dir := range dirs { configFile := filepath.Join(dir, core.CHALLENGE_CONFIG_FILE_NAME) - var config cfg.BeastChallengeConfig - _, err := toml.DecodeFile(configFile, &config) + config, err := cfg.LoadChallengeConfig(configFile) if err != nil { log.Errorf("Error while decoding challenge config file for challenge dir %s: %s", dir, err.Error()) continue From 0bb7750aad12cf6589c66d6efe7342a90eda6131 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Wed, 15 Jul 2026 22:56:32 +0530 Subject: [PATCH 007/149] fix(files): confine resolved paths to their root Signed-off-by: vibhatsu --- utils/file.go | 36 +++++++++++++++++++++++++++ utils/file_test.go | 61 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 utils/file_test.go diff --git a/utils/file.go b/utils/file.go index 42b24d2f..c18c711c 100644 --- a/utils/file.go +++ b/utils/file.go @@ -69,6 +69,42 @@ func ValidateFileExists(filePath string) error { return nil } +// ResolvePathWithin resolves an existing relative path and verifies that it +// remains inside root, including after following symbolic links. +func ResolvePathWithin(root, relativePath string) (string, error) { + if relativePath == "" || filepath.IsAbs(relativePath) { + return "", fmt.Errorf("path must be relative: %q", relativePath) + } + + rootPath, err := filepath.Abs(root) + if err != nil { + return "", fmt.Errorf("resolve root path: %w", err) + } + rootPath, err = filepath.EvalSymlinks(rootPath) + if err != nil { + return "", fmt.Errorf("resolve root symlinks: %w", err) + } + + targetPath, err := filepath.Abs(filepath.Join(rootPath, relativePath)) + if err != nil { + return "", fmt.Errorf("resolve path: %w", err) + } + resolvedPath, err := filepath.EvalSymlinks(targetPath) + if err != nil { + return "", fmt.Errorf("resolve path symlinks: %w", err) + } + + rel, err := filepath.Rel(rootPath, resolvedPath) + if err != nil { + return "", fmt.Errorf("compare path to root: %w", err) + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + return "", fmt.Errorf("path escapes root: %q", relativePath) + } + + return resolvedPath, nil +} + // Create the directory sequence in dirPath if it does not exist // if there was an error while creating the directory it returns the error // else it returns nil indicating success diff --git a/utils/file_test.go b/utils/file_test.go new file mode 100644 index 00000000..ed6c02ca --- /dev/null +++ b/utils/file_test.go @@ -0,0 +1,61 @@ +package utils + +import ( + "os" + "path/filepath" + "testing" +) + +func TestResolvePathWithinRejectsTraversal(t *testing.T) { + parent := t.TempDir() + root := filepath.Join(parent, "challenge") + if err := os.Mkdir(root, 0700); err != nil { + t.Fatal(err) + } + outside := filepath.Join(parent, "secret") + if err := os.WriteFile(outside, []byte("secret"), 0600); err != nil { + t.Fatal(err) + } + + if _, err := ResolvePathWithin(root, "../secret"); err == nil { + t.Fatal("expected traversal to be rejected") + } +} + +func TestResolvePathWithinRejectsEscapingSymlink(t *testing.T) { + parent := t.TempDir() + root := filepath.Join(parent, "challenge") + if err := os.Mkdir(root, 0700); err != nil { + t.Fatal(err) + } + outside := filepath.Join(parent, "secret") + if err := os.WriteFile(outside, []byte("secret"), 0600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, filepath.Join(root, "link")); err != nil { + t.Fatal(err) + } + + if _, err := ResolvePathWithin(root, "link"); err == nil { + t.Fatal("expected escaping symlink to be rejected") + } +} + +func TestResolvePathWithinAcceptsContainedPath(t *testing.T) { + root := t.TempDir() + file := filepath.Join(root, "nested", "file") + if err := os.Mkdir(filepath.Dir(file), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(file, nil, 0600); err != nil { + t.Fatal(err) + } + + resolved, err := ResolvePathWithin(root, "nested/file") + if err != nil { + t.Fatal(err) + } + if resolved != file { + t.Fatalf("resolved %q, want %q", resolved, file) + } +} From dc0e3cb49d6ba0796d10626b414ece297e01bb69 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Wed, 15 Jul 2026 22:57:58 +0530 Subject: [PATCH 008/149] fix(challenges): block paths outside challenge roots Signed-off-by: vibhatsu --- core/config/challenge.go | 82 ++++++++++++++++++++++++--------------- core/config/utils_test.go | 38 ++++++++++++++++++ 2 files changed, 88 insertions(+), 32 deletions(-) diff --git a/core/config/challenge.go b/core/config/challenge.go index b04d7d6a..441ec172 100644 --- a/core/config/challenge.go +++ b/core/config/challenge.go @@ -3,7 +3,6 @@ package config import ( "errors" "fmt" - "path/filepath" "strings" "github.com/sdslabs/beastv4/core" @@ -301,6 +300,28 @@ func (config *ChallengeEnv) GetDefaultPort() uint32 { return ports[0] } +func validateChallengeFile(challengeDir, relativePath, field string) error { + resolvedPath, err := utils.ResolvePathWithin(challengeDir, relativePath) + if err != nil { + return fmt.Errorf("invalid %s %q: %w", field, relativePath, err) + } + if err := utils.ValidateFileExists(resolvedPath); err != nil { + return fmt.Errorf("invalid %s %q: %w", field, relativePath, err) + } + return nil +} + +func validateChallengeDir(challengeDir, relativePath, field string) error { + resolvedPath, err := utils.ResolvePathWithin(challengeDir, relativePath) + if err != nil { + return fmt.Errorf("invalid %s %q: %w", field, relativePath, err) + } + if err := utils.ValidateDirExists(resolvedPath); err != nil { + return fmt.Errorf("invalid %s %q: %w", field, relativePath, err) + } + return nil +} + // ValidateRequiredFields validates required fields for the Challenge environment configuration. // This requires challenge type to be passed so that we can verfiy based on type // of the challenge. @@ -308,10 +329,7 @@ func (config *ChallengeEnv) ValidateRequiredFields(challType string, challdir st // Validate port related stuff for the challenge environment configuration. if config.StaticContentDir != "" { - if filepath.IsAbs(config.StaticContentDir) { - return fmt.Errorf("static content directory path should be relative to challenge directory root") - } - if err := utils.ValidateDirExists(filepath.Join(challdir, config.StaticContentDir)); err != nil { + if err := validateChallengeDir(challdir, config.StaticContentDir, "static_dir"); err != nil { return err } } @@ -321,11 +339,8 @@ func (config *ChallengeEnv) ValidateRequiredFields(challType string, challdir st } if config.DockerCompose != "" { - if filepath.IsAbs(config.DockerCompose) { - return fmt.Errorf("docker_compose path should be relative to challenge directory root") - } - if err := utils.ValidateFileExists(filepath.Join(challdir, config.DockerCompose)); err != nil { - return fmt.Errorf("docker_compose file does not exist: %s", config.DockerCompose) + if err := validateChallengeFile(challdir, config.DockerCompose, "docker_compose"); err != nil { + return err } // Warn if other configuration fields are specified when docker_compose is provided @@ -376,16 +391,23 @@ func (config *ChallengeEnv) ValidateRequiredFields(challType string, challdir st if !utils.StringInSlice(config.BaseImage, Cfg.AllowedBaseImages) { return fmt.Errorf("the base image: %s is not supported", config.BaseImage) } + if config.DockerCtx != "" { + if err := validateChallengeFile(challdir, config.DockerCtx, "docker_context"); err != nil { + return err + } + } + if config.XinetdConf != "" { + if err := validateChallengeFile(challdir, config.XinetdConf, "xinetd_conf"); err != nil { + return err + } + } if challType == core.SERVICE_CHALLENGE_TYPE_NAME { // Challenge type is service. // ServicePath must be relative. if config.ServicePath != "" { - if filepath.IsAbs(config.ServicePath) { - return fmt.Errorf("for challenge type `services` service_path is a required variable, which should be relative path to executable") - } else if err := utils.ValidateFileExists(filepath.Join(challdir, config.ServicePath)); err != nil { - // Skip this, we might create service later too. - log.Warnf("Service path file %s does not exist", config.ServicePath) + if err := validateChallengeFile(challdir, config.ServicePath, "service_path"); err != nil { + return err } } } else if strings.HasPrefix(challType, core.WEB_CHALLENGE_TYPE_NAME) { @@ -393,35 +415,27 @@ func (config *ChallengeEnv) ValidateRequiredFields(challType string, challdir st if config.WebRoot == "" && config.DockerCtx == "" && config.DockerCompose == "" { return errors.New("web root can not be empty for web challenges without custom dockerfile or docker-compose") } else if config.WebRoot != "" { - if filepath.IsAbs(config.WebRoot) { - return fmt.Errorf("web Root directory path should be relative to challenge directory root") - } else if err := utils.ValidateDirExists(filepath.Join(challdir, config.WebRoot)); err != nil { - return fmt.Errorf("web Root directory does not exist") + if err := validateChallengeDir(challdir, config.WebRoot, "web_root"); err != nil { + return err } } } for _, script := range config.SetupScripts { - if filepath.IsAbs(script) { - return fmt.Errorf("script path is absolute : %s", script) - } else if err := utils.ValidateFileExists(filepath.Join(challdir, script)); err != nil { - return fmt.Errorf("file %s does not exist", script) + if err := validateChallengeFile(challdir, script, "setup_scripts"); err != nil { + return err } } for _, env := range config.EnvironmentVars { - if filepath.IsAbs(env.Value) { - return fmt.Errorf("environment Variable contains absolute path : %s", env.Value) - } else if err := utils.ValidateFileExists(filepath.Join(challdir, env.Value)); err != nil { - return fmt.Errorf("file %s does not exist", env.Value) + if err := validateChallengeFile(challdir, env.Value, "environment variable value"); err != nil { + return err } } if config.Entrypoint != "" { - if filepath.IsAbs(config.Entrypoint) { - return fmt.Errorf("entrypoint contains absolute path : %s", config.Entrypoint) - } else if err := utils.ValidateFileExists(filepath.Join(challdir, config.Entrypoint)); err != nil { - return fmt.Errorf("file %s does not exist", config.Entrypoint) + if err := validateChallengeFile(challdir, config.Entrypoint, "entrypoint"); err != nil { + return err } } @@ -454,7 +468,11 @@ func (config *ChallengeEnv) ExtractPorts() error { func (config *ChallengeEnv) ExtractPortsCompose(challdir string) error { if config.DockerCompose != "" { - portVariables, err := utils.ExtractPortsFromCompose(filepath.Join(challdir, config.DockerCompose)) + composePath, err := utils.ResolvePathWithin(challdir, config.DockerCompose) + if err != nil { + return err + } + portVariables, err := utils.ExtractPortsFromCompose(composePath) if err != nil { log.Warnf("failed to extract port variables from compose file with the following error : %s", err.Error()) } diff --git a/core/config/utils_test.go b/core/config/utils_test.go index 0df42674..9f98c93f 100644 --- a/core/config/utils_test.go +++ b/core/config/utils_test.go @@ -35,3 +35,41 @@ func TestLoadChallengeConfigAcceptsKnownKeys(t *testing.T) { t.Fatalf("unexpected author email %q", config.Author.Email) } } + +func TestChallengeEnvRejectsEscapingPaths(t *testing.T) { + parent := t.TempDir() + challengeDir := filepath.Join(parent, "challenge") + if err := os.Mkdir(challengeDir, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(parent, "outside"), nil, 0600); err != nil { + t.Fatal(err) + } + + env := ChallengeEnv{StaticContentDir: "../outside"} + err := env.ValidateRequiredFields("static", challengeDir) + if err == nil || !strings.Contains(err.Error(), "escapes root") { + t.Fatalf("expected escaping path error, got %v", err) + } +} + +func TestChallengeEnvRejectsEscapingComposeSymlink(t *testing.T) { + parent := t.TempDir() + challengeDir := filepath.Join(parent, "challenge") + if err := os.Mkdir(challengeDir, 0700); err != nil { + t.Fatal(err) + } + outside := filepath.Join(parent, "compose.yml") + if err := os.WriteFile(outside, []byte("services: {}\n"), 0600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, filepath.Join(challengeDir, "compose.yml")); err != nil { + t.Fatal(err) + } + + env := ChallengeEnv{DockerCompose: "compose.yml"} + err := env.ValidateRequiredFields("bare", challengeDir) + if err == nil || !strings.Contains(err.Error(), "escapes root") { + t.Fatalf("expected escaping symlink error, got %v", err) + } +} From b78b650254cfe86707b976fa87b4da42f26ce574 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Wed, 15 Jul 2026 22:58:34 +0530 Subject: [PATCH 009/149] fix(staging): reject special archive entries Signed-off-by: vibhatsu --- utils/archive.go | 3 +++ utils/archive_test.go | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 utils/archive_test.go diff --git a/utils/archive.go b/utils/archive.go index 47955eb1..7045ff89 100644 --- a/utils/archive.go +++ b/utils/archive.go @@ -80,6 +80,9 @@ func Tar(contextDir string, compression Compression, destinationDir string, addi log.Debugf("skipping a dir without errors: %s", info.Name()) return filepath.SkipDir } + if !info.IsDir() && !info.Mode().IsRegular() { + return fmt.Errorf("unsupported file type in archive context: %s", path) + } header, err := tar.FileInfoHeader(info, info.Name()) if err != nil { diff --git a/utils/archive_test.go b/utils/archive_test.go new file mode 100644 index 00000000..9831bdc7 --- /dev/null +++ b/utils/archive_test.go @@ -0,0 +1,35 @@ +package utils + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestTarRejectsSymlinks(t *testing.T) { + parent := t.TempDir() + contextDir := filepath.Join(parent, "challenge") + destinationDir := filepath.Join(parent, "staging") + if err := os.Mkdir(contextDir, 0700); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(destinationDir, 0700); err != nil { + t.Fatal(err) + } + outside := filepath.Join(parent, "secret") + if err := os.WriteFile(outside, []byte("secret"), 0600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, filepath.Join(contextDir, "link")); err != nil { + t.Fatal(err) + } + + err := Tar(contextDir, Gzip, destinationDir, nil, nil) + if err == nil || !strings.Contains(err.Error(), "unsupported file type") { + t.Fatalf("expected unsupported file error, got %v", err) + } + if _, statErr := os.Stat(filepath.Join(destinationDir, "challenge.tar.gz")); !os.IsNotExist(statErr) { + t.Fatalf("partial archive was not removed: %v", statErr) + } +} From 5f7bdfed142dfc6c01cb95933d9bacbc22bf48b4 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Wed, 15 Jul 2026 22:59:48 +0530 Subject: [PATCH 010/149] fix(staging): fail closed on archive errors Signed-off-by: vibhatsu --- utils/archive.go | 38 ++++++++++++++++++++++++++++---------- utils/archive_test.go | 22 ++++++++++++++++++++++ 2 files changed, 50 insertions(+), 10 deletions(-) diff --git a/utils/archive.go b/utils/archive.go index 7045ff89..98c327fc 100644 --- a/utils/archive.go +++ b/utils/archive.go @@ -51,6 +51,12 @@ func Tar(contextDir string, compression Compression, destinationDir string, addi if err != nil { return fmt.Errorf("error while creating tar :: %s", target) } + complete := false + defer func() { + if !complete { + _ = os.Remove(target) + } + }() defer targetFile.Close() // Create a Gzipped tar file writer from the file we just opened @@ -115,31 +121,33 @@ func Tar(contextDir string, compression Compression, destinationDir string, addi for fileName, filePath := range additionalCtx { fileInfo, err := CheckPathValid(filePath) if err != nil || !fileInfo.Mode().IsRegular() { - log.Errorf("Cannot find a valid file for %s while creating tar... Continuing", filePath) - continue + return fmt.Errorf("invalid additional archive file %s", filePath) } header, err := tar.FileInfoHeader(fileInfo, fileInfo.Name()) if err != nil { - log.Errorf("Cannot create a valid tar header for %s while creating tar... Continuing", filePath) - continue + return fmt.Errorf("create archive header for %s: %w", filePath, err) } header.Name = filepath.Join(fileName) if err := tarFileWriter.WriteHeader(header); err != nil { - log.Errorf("Cannot write tar header for %s while creating tar... Continuing", filePath) - continue + return fmt.Errorf("write archive header for %s: %w", filePath, err) } - curFile, _ := os.Open(filePath) + curFile, err := os.Open(filePath) + if err != nil { + return fmt.Errorf("open additional archive file %s: %w", filePath, err) + } _, err = io.Copy(tarFileWriter, curFile) + closeErr := curFile.Close() if err != nil { - log.Errorf("Cannot write file to tar %s... Continuing", filePath) - continue + return fmt.Errorf("write additional archive file %s: %w", filePath, err) + } + if closeErr != nil { + return fmt.Errorf("close additional archive file %s: %w", filePath, closeErr) } - curFile.Close() } if err != nil { @@ -153,6 +161,16 @@ func Tar(contextDir string, compression Compression, destinationDir string, addi return fmt.Errorf("error while creating Tar :: %s", err) } + if err := tarFileWriter.Close(); err != nil { + return fmt.Errorf("finalize tar archive: %w", err) + } + if err := fileWriter.Close(); err != nil { + return fmt.Errorf("finalize gzip archive: %w", err) + } + if err := targetFile.Close(); err != nil { + return fmt.Errorf("close archive: %w", err) + } + complete = true return nil } diff --git a/utils/archive_test.go b/utils/archive_test.go index 9831bdc7..475cc6bf 100644 --- a/utils/archive_test.go +++ b/utils/archive_test.go @@ -33,3 +33,25 @@ func TestTarRejectsSymlinks(t *testing.T) { t.Fatalf("partial archive was not removed: %v", statErr) } } + +func TestTarFailsWhenAdditionalContextIsMissing(t *testing.T) { + parent := t.TempDir() + contextDir := filepath.Join(parent, "challenge") + destinationDir := filepath.Join(parent, "staging") + if err := os.Mkdir(contextDir, 0700); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(destinationDir, 0700); err != nil { + t.Fatal(err) + } + + err := Tar(contextDir, Gzip, destinationDir, map[string]string{ + "Dockerfile": filepath.Join(parent, "missing"), + }, nil) + if err == nil || !strings.Contains(err.Error(), "invalid additional archive file") { + t.Fatalf("expected missing additional file error, got %v", err) + } + if _, statErr := os.Stat(filepath.Join(destinationDir, "challenge.tar.gz")); !os.IsNotExist(statErr) { + t.Fatalf("partial archive was not removed: %v", statErr) + } +} From f2112d51bf5d5338dc53961bc25804643e7ce30a Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Wed, 15 Jul 2026 23:03:59 +0530 Subject: [PATCH 011/149] fix(compose): reject host escape primitives Signed-off-by: vibhatsu --- utils/compose.go | 241 +++++++++++++++++++++++++++++++++++++++++- utils/compose_test.go | 86 +++++++++++++++ 2 files changed, 323 insertions(+), 4 deletions(-) create mode 100644 utils/compose_test.go diff --git a/utils/compose.go b/utils/compose.go index 650188eb..d442fac0 100644 --- a/utils/compose.go +++ b/utils/compose.go @@ -2,19 +2,246 @@ package utils import ( "fmt" - "gopkg.in/yaml.v2" "os" + "path/filepath" "regexp" "strings" + + "gopkg.in/yaml.v2" ) +type composeService struct { + Build interface{} `yaml:"build"` + Ports []string `yaml:"ports"` + Privileged bool `yaml:"privileged"` + NetworkMode string `yaml:"network_mode"` + PID string `yaml:"pid"` + IPC string `yaml:"ipc"` + UTS string `yaml:"uts"` + UserNSMode string `yaml:"userns_mode"` + Runtime string `yaml:"runtime"` + CgroupParent string `yaml:"cgroup_parent"` + ContainerName string `yaml:"container_name"` + Volumes []interface{} `yaml:"volumes"` + Devices []string `yaml:"devices"` + CapAdd []string `yaml:"cap_add"` + SecurityOpt []string `yaml:"security_opt"` + DeviceCgroupRules []string `yaml:"device_cgroup_rules"` + Sysctls interface{} `yaml:"sysctls"` + Secrets interface{} `yaml:"secrets"` + Configs interface{} `yaml:"configs"` + EnvFile interface{} `yaml:"env_file"` + Extends interface{} `yaml:"extends"` + CredentialSpec interface{} `yaml:"credential_spec"` + VolumesFrom []string `yaml:"volumes_from"` + Links []string `yaml:"links"` + ExternalLinks []string `yaml:"external_links"` +} + +type composeResource struct { + External interface{} `yaml:"external"` +} + type Compose struct { - Services map[string]struct { - Ports []string `yaml:"ports"` - } `yaml:"services"` + Services map[string]composeService `yaml:"services"` + Volumes map[string]composeResource `yaml:"volumes"` + Networks map[string]composeResource `yaml:"networks"` + Secrets interface{} `yaml:"secrets"` + Configs interface{} `yaml:"configs"` } var portRegex = regexp.MustCompile(`\$\{([^}]+)}`) +var portVariableRegex = regexp.MustCompile(`^[A-Z_][A-Z0-9_]*$`) + +func composeResourceIsExternal(value interface{}) bool { + switch external := value.(type) { + case nil: + return false + case bool: + return external + default: + return true + } +} + +func validateComposeVolume(composeDir string, value interface{}) error { + if volume, ok := value.(string); ok { + parts := strings.Split(volume, ":") + if len(parts) == 1 { + return nil + } + source := parts[0] + if source == "" || strings.Contains(source, "${") { + return fmt.Errorf("volume source %q cannot be validated", source) + } + if !strings.Contains(source, "/") && !strings.HasPrefix(source, ".") && !strings.HasPrefix(source, "~") { + return nil + } + if filepath.IsAbs(source) || strings.HasPrefix(source, "~") { + return fmt.Errorf("host volume source is not allowed: %s", source) + } + if _, err := ResolvePathWithin(composeDir, source); err != nil { + return fmt.Errorf("invalid project volume source %q: %w", source, err) + } + return nil + } + + encoded, err := yaml.Marshal(value) + if err != nil { + return fmt.Errorf("parse volume definition: %w", err) + } + var volume struct { + Type string `yaml:"type"` + Source string `yaml:"source"` + } + if err := yaml.Unmarshal(encoded, &volume); err != nil { + return fmt.Errorf("parse volume definition: %w", err) + } + if volume.Type == "volume" || (volume.Type == "" && volume.Source != "") { + return nil + } + if volume.Type != "bind" { + return fmt.Errorf("unsupported volume type %q", volume.Type) + } + if filepath.IsAbs(volume.Source) || strings.HasPrefix(volume.Source, "~") { + return fmt.Errorf("host volume source is not allowed: %s", volume.Source) + } + if _, err := ResolvePathWithin(composeDir, volume.Source); err != nil { + return fmt.Errorf("invalid project volume source %q: %w", volume.Source, err) + } + return nil +} + +func validateComposeBuild(composeDir string, value interface{}) error { + if value == nil { + return nil + } + if contextPath, ok := value.(string); ok { + if _, err := ResolvePathWithin(composeDir, contextPath); err != nil { + return fmt.Errorf("invalid build context %q: %w", contextPath, err) + } + return nil + } + + encoded, err := yaml.Marshal(value) + if err != nil { + return fmt.Errorf("parse build definition: %w", err) + } + var build struct { + Context string `yaml:"context"` + Dockerfile string `yaml:"dockerfile"` + AdditionalContexts interface{} `yaml:"additional_contexts"` + Secrets interface{} `yaml:"secrets"` + SSH interface{} `yaml:"ssh"` + } + if err := yaml.Unmarshal(encoded, &build); err != nil { + return fmt.Errorf("parse build definition: %w", err) + } + if build.AdditionalContexts != nil || build.Secrets != nil || build.SSH != nil { + return fmt.Errorf("build additional_contexts, secrets, and ssh are not allowed") + } + if build.Context == "" { + build.Context = "." + } + if _, err := ResolvePathWithin(composeDir, build.Context); err != nil { + return fmt.Errorf("invalid build context %q: %w", build.Context, err) + } + if build.Dockerfile != "" { + dockerfilePath := filepath.Join(build.Context, build.Dockerfile) + if _, err := ResolvePathWithin(composeDir, dockerfilePath); err != nil { + return fmt.Errorf("invalid build dockerfile %q: %w", build.Dockerfile, err) + } + } + return nil +} + +func validateComposeEnvFiles(composeDir string, value interface{}) error { + if value == nil { + return nil + } + values, ok := value.([]interface{}) + if !ok { + values = []interface{}{value} + } + for _, item := range values { + path, ok := item.(string) + if !ok { + encoded, err := yaml.Marshal(item) + if err != nil { + return fmt.Errorf("parse env_file: %w", err) + } + var entry struct { + Path string `yaml:"path"` + } + if err := yaml.Unmarshal(encoded, &entry); err != nil { + return fmt.Errorf("parse env_file: %w", err) + } + path = entry.Path + } + if _, err := ResolvePathWithin(composeDir, path); err != nil { + return fmt.Errorf("invalid env_file %q: %w", path, err) + } + } + return nil +} + +func validateComposeSecurity(composeFile string, compose Compose) error { + if compose.Secrets != nil || compose.Configs != nil { + return fmt.Errorf("top-level secrets and configs are not allowed") + } + for name, resource := range compose.Volumes { + if composeResourceIsExternal(resource.External) { + return fmt.Errorf("external volume %q is not allowed", name) + } + } + for name, resource := range compose.Networks { + if composeResourceIsExternal(resource.External) { + return fmt.Errorf("external network %q is not allowed", name) + } + } + + composeDir := filepath.Dir(composeFile) + for name, service := range compose.Services { + if service.Privileged { + return fmt.Errorf("service %q cannot be privileged", name) + } + if service.NetworkMode != "" && service.NetworkMode != "bridge" && service.NetworkMode != "none" { + return fmt.Errorf("service %q uses forbidden network_mode %q", name, service.NetworkMode) + } + if service.PID != "" || service.IPC != "" || service.UTS != "" || service.UserNSMode != "" { + return fmt.Errorf("service %q requests a host namespace", name) + } + if service.Runtime != "" || service.CgroupParent != "" || service.ContainerName != "" { + return fmt.Errorf("service %q overrides host-managed container settings", name) + } + if len(service.Devices) > 0 || len(service.CapAdd) > 0 || len(service.DeviceCgroupRules) > 0 || service.Sysctls != nil { + return fmt.Errorf("service %q requests additional host privileges", name) + } + if service.Secrets != nil || service.Configs != nil { + return fmt.Errorf("service %q uses unsupported secrets or configs", name) + } + if service.Extends != nil || service.CredentialSpec != nil || len(service.VolumesFrom) > 0 || len(service.Links) > 0 || len(service.ExternalLinks) > 0 { + return fmt.Errorf("service %q references host-managed services or credentials", name) + } + if err := validateComposeBuild(composeDir, service.Build); err != nil { + return fmt.Errorf("service %q: %w", name, err) + } + if err := validateComposeEnvFiles(composeDir, service.EnvFile); err != nil { + return fmt.Errorf("service %q: %w", name, err) + } + for _, option := range service.SecurityOpt { + if option != "no-new-privileges:true" { + return fmt.Errorf("service %q uses forbidden security_opt %q", name, option) + } + } + for _, volume := range service.Volumes { + if err := validateComposeVolume(composeDir, volume); err != nil { + return fmt.Errorf("service %q: %w", name, err) + } + } + } + return nil +} func ExtractPortsFromCompose(composeFile string) ([]string, error) { data, err := os.ReadFile(composeFile) @@ -26,6 +253,9 @@ func ExtractPortsFromCompose(composeFile string) ([]string, error) { if err != nil { return nil, fmt.Errorf("error while parsing compose file: %s", err.Error()) } + if err := validateComposeSecurity(composeFile, raw); err != nil { + return nil, err + } portVariables := make([]string, 0) seen := make(map[string]bool) @@ -45,6 +275,9 @@ func ExtractPortsFromCompose(composeFile string) ([]string, error) { return nil, fmt.Errorf("port variable ${%s} uses default value syntax (:-) which is not supported; use ${%s} instead", varName, strings.SplitN(varName, ":-", 2)[0]) } + if !portVariableRegex.MatchString(varName) { + return nil, fmt.Errorf("invalid port variable name %q", varName) + } if !seen[varName] { seen[varName] = true diff --git a/utils/compose_test.go b/utils/compose_test.go new file mode 100644 index 00000000..c8b265cf --- /dev/null +++ b/utils/compose_test.go @@ -0,0 +1,86 @@ +package utils + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func writeCompose(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "compose.yml") + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + t.Fatal(err) + } + return path +} + +func TestExtractPortsFromComposeRejectsHostEscapeSettings(t *testing.T) { + tests := []struct { + name string + setting string + want string + }{ + {name: "privileged", setting: " privileged: true\n", want: "cannot be privileged"}, + {name: "host network", setting: " network_mode: host\n", want: "forbidden network_mode"}, + {name: "host pid", setting: " pid: host\n", want: "host namespace"}, + {name: "device", setting: " devices: [/dev/kvm:/dev/kvm]\n", want: "additional host privileges"}, + {name: "capability", setting: " cap_add: [SYS_ADMIN]\n", want: "additional host privileges"}, + {name: "runtime", setting: " runtime: runc\n", want: "host-managed container settings"}, + {name: "socket", setting: " volumes: [/var/run/docker.sock:/var/run/docker.sock]\n", want: "host volume source"}, + {name: "build escape", setting: " build: ..\n", want: "invalid build context"}, + {name: "env escape", setting: " env_file: ../secret.env\n", want: "invalid env_file"}, + {name: "volumes from", setting: " volumes_from: [host-container]\n", want: "host-managed services"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + path := writeCompose(t, "services:\n app:\n"+test.setting+" ports: [\"${APP_PORT}:80\"]\n") + _, err := ExtractPortsFromCompose(path) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("expected %q error, got %v", test.want, err) + } + }) + } +} + +func TestExtractPortsFromComposeAllowsProjectBindMount(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "init.sql"), nil, 0600); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "compose.yml") + content := "services:\n app:\n volumes: [\"./init.sql:/init.sql:ro\"]\n ports: [\"${APP_PORT}:80\"]\n" + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + t.Fatal(err) + } + + ports, err := ExtractPortsFromCompose(path) + if err != nil { + t.Fatal(err) + } + if len(ports) != 1 || ports[0] != "APP_PORT" { + t.Fatalf("unexpected ports: %v", ports) + } +} + +func TestExtractPortsFromComposeRejectsEscapingBindMount(t *testing.T) { + parent := t.TempDir() + dir := filepath.Join(parent, "challenge") + if err := os.Mkdir(dir, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(parent, "secret"), nil, 0600); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "compose.yml") + content := "services:\n app:\n volumes: [\"../secret:/secret:ro\"]\n ports: [\"${APP_PORT}:80\"]\n" + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + t.Fatal(err) + } + + _, err := ExtractPortsFromCompose(path) + if err == nil || !strings.Contains(err.Error(), "escapes root") { + t.Fatalf("expected escaping bind mount error, got %v", err) + } +} From fdba53ddec7c601e95df385bb72c5ce3a800d08c Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Wed, 15 Jul 2026 23:05:34 +0530 Subject: [PATCH 012/149] fix(compose): avoid local shell command construction Signed-off-by: vibhatsu --- pkg/cr/images.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pkg/cr/images.go b/pkg/cr/images.go index fe4f6a39..4528f652 100644 --- a/pkg/cr/images.go +++ b/pkg/cr/images.go @@ -6,7 +6,6 @@ import ( "os" "os/exec" "path/filepath" - "strings" "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/filters" @@ -117,8 +116,10 @@ func BuildImageFromTarContext(challengeName, challengeTag, tarContextPath, docke func BuildImagesFromCompose(challengeName, challengeTag, stagedPath, ComposeFile string, noCache bool) (*bytes.Buffer, error) { extractPath := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_STAGING_DIR, challengeName, challengeName) - extractCmd := fmt.Sprintf("mkdir -p %s && tar -xf %s -C %s", extractPath, stagedPath, extractPath) - err := exec.Command("bash", "-c", extractCmd).Run() + if err := os.MkdirAll(extractPath, 0750); err != nil { + return nil, fmt.Errorf("create compose extraction directory %s: %w", extractPath, err) + } + err := exec.Command("tar", "-xf", stagedPath, "-C", extractPath).Run() if err != nil { return nil, fmt.Errorf("error while extracting tar file %s to %s: %v", stagedPath, extractPath, err) } @@ -129,11 +130,10 @@ func BuildImagesFromCompose(challengeName, challengeTag, stagedPath, ComposeFile } // Note: docker compose build does not support --label flag // Labels are automatically added to containers during 'docker compose up -p ' - composeCmd := fmt.Sprintf("docker %s", strings.Join(cmdArgs, " ")) log.Debugf("Building image for challenge %s with tag %s", challengeName, challengeTag) log.Debugf("Running the command: docker %v", cmdArgs) - cmd := exec.Command("bash", "-c", composeCmd) + cmd := exec.Command("docker", cmdArgs...) cmd.Dir = extractPath var outBuffer bytes.Buffer cmd.Stdout = &outBuffer From d162f39a36086449708f00ce4ca4a0661c718f12 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Wed, 15 Jul 2026 23:06:29 +0530 Subject: [PATCH 013/149] fix(challenges): constrain challenge identifiers Signed-off-by: vibhatsu --- core/config/challenge.go | 6 ++++++ core/config/utils_test.go | 13 +++++++++++++ 2 files changed, 19 insertions(+) diff --git a/core/config/challenge.go b/core/config/challenge.go index 441ec172..dc37cd41 100644 --- a/core/config/challenge.go +++ b/core/config/challenge.go @@ -3,6 +3,7 @@ package config import ( "errors" "fmt" + "regexp" "strings" "github.com/sdslabs/beastv4/core" @@ -15,6 +16,8 @@ import ( const SERVICE_CONTAINER_DEPS string = "xinetd" const SERVICE_CHALL_RUN_CMD string = "xinetd -dontfork" +var challengeNamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,63}$`) + // This is the beast challenge config file structure // any other field specified in the file other than this structure // will be ignored. @@ -168,6 +171,9 @@ func (config *ChallengeMetadata) ValidateRequiredFields() (error, bool) { if config.Name == "" || (config.Flag == "" && !config.DynamicFlag) { return fmt.Errorf("name and flag required for the challenge"), false } + if !challengeNamePattern.MatchString(config.Name) { + return fmt.Errorf("challenge name must match %s", challengeNamePattern.String()), false + } // Checks if fail solve limit is provided and is greater than 0 if config.MaxAttemptLimit < 0 { diff --git a/core/config/utils_test.go b/core/config/utils_test.go index 9f98c93f..a6158191 100644 --- a/core/config/utils_test.go +++ b/core/config/utils_test.go @@ -73,3 +73,16 @@ func TestChallengeEnvRejectsEscapingComposeSymlink(t *testing.T) { t.Fatalf("expected escaping symlink error, got %v", err) } } + +func TestChallengeMetadataRejectsUnsafeNames(t *testing.T) { + tests := []string{"../../escape", "name; touch pwned", "UPPERCASE", strings.Repeat("a", 65)} + for _, name := range tests { + t.Run(name, func(t *testing.T) { + metadata := ChallengeMetadata{Name: name, Flag: "flag", Type: "static"} + err, _ := metadata.ValidateRequiredFields() + if err == nil || !strings.Contains(err.Error(), "must match") { + t.Fatalf("expected unsafe name error, got %v", err) + } + }) + } +} From cafd5f29e76f976c0977e3f734859456f5e7edf5 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Wed, 15 Jul 2026 23:07:19 +0530 Subject: [PATCH 014/149] fix(config): protect secrets in global configuration Signed-off-by: vibhatsu --- cmd/beast/config.go | 6 +++++- core/config/config.go | 22 +++++++++++++++------- core/config/utils_test.go | 29 +++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 8 deletions(-) diff --git a/cmd/beast/config.go b/cmd/beast/config.go index a19e9d6c..4d410b89 100644 --- a/cmd/beast/config.go +++ b/cmd/beast/config.go @@ -225,10 +225,14 @@ func tryCopyExampleConfig() error { promptBeastConfiguration(&configuration) - file, err := os.Create(BEAST_GLOBAL_CONFIG) + file, err := os.OpenFile(BEAST_GLOBAL_CONFIG, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) if err != nil { return err } + if err := file.Chmod(0600); err != nil { + file.Close() + return err + } defer file.Close() encoder := toml.NewEncoder(file) diff --git a/core/config/config.go b/core/config/config.go index 7e2fbd1e..057faf2f 100644 --- a/core/config/config.go +++ b/core/config/config.go @@ -1,7 +1,6 @@ package config import ( - "encoding/json" "errors" "fmt" "net/url" @@ -484,17 +483,28 @@ func GetCompetitionInfo() (CompetitionInfo, error) { func LoadBeastConfig(configPath string) (BeastConfig, error) { var config BeastConfig - err := utils.ValidateFileExists(configPath) + info, err := os.Lstat(configPath) if err != nil { return config, err } + if info.Mode()&os.ModeSymlink != 0 { + return config, fmt.Errorf("global config must not be a symbolic link: %s", configPath) + } + if !info.Mode().IsRegular() { + return config, fmt.Errorf("global config is not a regular file: %s", configPath) + } + if info.Mode().Perm() != 0600 { + return config, fmt.Errorf("global config permissions must be 0600, got %04o", info.Mode().Perm()) + } - if err = decodeTOMLFileStrict(configPath, &config); err != nil { + err = utils.ValidateFileExists(configPath) + if err != nil { return config, err } - prettyJSON, _ := json.MarshalIndent(config, "", " ") - log.Debugf("Parsed beast global config file is : %s", string(prettyJSON)) + if err = decodeTOMLFileStrict(configPath, &config); err != nil { + return config, err + } err = config.ValidateConfig() if err != nil { @@ -525,7 +535,6 @@ func InitConfig() { os.Exit(1) } - log.Debugf("CONFIG LOAD: New Config : %v", cfg) Cfg = &cfg } @@ -540,6 +549,5 @@ func ReloadBeastConfig() error { } Cfg = &cfg - log.Debugf("CONFIG LOAD: New Config : %v", cfg) return nil } diff --git a/core/config/utils_test.go b/core/config/utils_test.go index a6158191..06924c12 100644 --- a/core/config/utils_test.go +++ b/core/config/utils_test.go @@ -86,3 +86,32 @@ func TestChallengeMetadataRejectsUnsafeNames(t *testing.T) { }) } } + +func TestLoadBeastConfigRejectsInsecurePermissions(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.toml") + if err := os.WriteFile(path, nil, 0644); err != nil { + t.Fatal(err) + } + + _, err := LoadBeastConfig(path) + if err == nil || !strings.Contains(err.Error(), "must be 0600") { + t.Fatalf("expected permissions error, got %v", err) + } +} + +func TestLoadBeastConfigRejectsSymlink(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "target.toml") + if err := os.WriteFile(target, nil, 0600); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "config.toml") + if err := os.Symlink(target, path); err != nil { + t.Fatal(err) + } + + _, err := LoadBeastConfig(path) + if err == nil || !strings.Contains(err.Error(), "must not be a symbolic link") { + t.Fatalf("expected symlink error, got %v", err) + } +} From 3525b0be952542bc1a565b90ba13444d820230c4 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Wed, 15 Jul 2026 23:09:32 +0530 Subject: [PATCH 015/149] fix(workers): make queue shutdown race safe Signed-off-by: vibhatsu --- pkg/workerpool/pool.go | 59 +++++++++++++++++++++++++------------ pkg/workerpool/pool_test.go | 51 ++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 19 deletions(-) create mode 100644 pkg/workerpool/pool_test.go diff --git a/pkg/workerpool/pool.go b/pkg/workerpool/pool.go index 4028c8e6..5a73e770 100644 --- a/pkg/workerpool/pool.go +++ b/pkg/workerpool/pool.go @@ -14,6 +14,10 @@ type Queue struct { InQueue map[string]bool // A map which stores if the task related to some id is already in the queue CompletionChannel chan bool + stopChannel chan struct{} + stopOnce sync.Once + workers sync.WaitGroup + stopped bool } type Task struct { @@ -27,16 +31,19 @@ type Worker interface { func (q *Queue) Push(w Task) error { q.Mux.Lock() + defer q.Mux.Unlock() + if q.stopped { + return fmt.Errorf("queue is stopped") + } if _, ex := q.InQueue[w.ID]; ex { - q.Mux.Unlock() log.Warnf("The Task ID : %s is already in queue", w.ID) return fmt.Errorf("The Task ID : %s is already in queue", w.ID) } q.InQueue[w.ID] = true - q.Mux.Unlock() select { case q.TaskQueue <- w: default: + delete(q.InQueue, w.ID) return fmt.Errorf("Queue is full") } // TODO : get size of the queue @@ -46,34 +53,46 @@ func (q *Queue) Push(w Task) error { func (q *Queue) Pop(ID string) { q.Mux.Lock() delete(q.InQueue, ID) - if q.CompletionChannel != nil && len(q.InQueue) == 0 { - q.CompletionChannel <- true - } + completed := q.CompletionChannel != nil && len(q.InQueue) == 0 q.Mux.Unlock() + if completed { + select { + case q.CompletionChannel <- true: + default: + } + } } func (q *Queue) Stop() { - ids := make([]string, len(q.InQueue)) - i := 0 - for id := range q.InQueue { - ids[i] = id - i++ - } + q.stopOnce.Do(func() { + q.Mux.Lock() + q.stopped = true + close(q.stopChannel) + q.Mux.Unlock() + q.workers.Wait() - for _, id := range ids { - q.Pop(id) - } + q.Mux.Lock() + for id := range q.InQueue { + delete(q.InQueue, id) + } + q.Mux.Unlock() + }) } func (q *Queue) startConcurrentWorker(i int, worker Worker) { + defer q.workers.Done() for { - w := <-q.TaskQueue - newTask := worker.PerformTask(w) + select { + case <-q.stopChannel: + return + case w := <-q.TaskQueue: + newTask := worker.PerformTask(w) - q.Pop(w.ID) + q.Pop(w.ID) - if newTask != nil { - q.Push(*newTask) + if newTask != nil { + _ = q.Push(*newTask) + } } } } @@ -81,6 +100,7 @@ func (q *Queue) startConcurrentWorker(i int, worker Worker) { func (q *Queue) StartWorkers(worker Worker) { numCPUs := runtime.NumCPU() log.Info("Total Workers: ", numCPUs) + q.workers.Add(numCPUs) for i := 0; i < numCPUs; i++ { go q.startConcurrentWorker(i, worker) } @@ -93,6 +113,7 @@ func InitQueue(maxQueueSize uint32, completionChannel chan bool) *Queue { Mux: sync.RWMutex{}, InQueue: map[string]bool{}, CompletionChannel: completionChannel, + stopChannel: make(chan struct{}), } return Q } diff --git a/pkg/workerpool/pool_test.go b/pkg/workerpool/pool_test.go new file mode 100644 index 00000000..447362aa --- /dev/null +++ b/pkg/workerpool/pool_test.go @@ -0,0 +1,51 @@ +package Taskerpool + +import ( + "sync/atomic" + "testing" + "time" +) + +type testWorker struct { + count atomic.Int32 +} + +func (worker *testWorker) PerformTask(Task) *Task { + worker.count.Add(1) + return nil +} + +func TestPushRollsBackFullQueueMarker(t *testing.T) { + queue := InitQueue(1, nil) + if err := queue.Push(Task{ID: "first"}); err != nil { + t.Fatal(err) + } + if err := queue.Push(Task{ID: "second"}); err == nil { + t.Fatal("expected full queue error") + } + queue.Mux.RLock() + _, marked := queue.InQueue["second"] + queue.Mux.RUnlock() + if marked { + t.Fatal("failed task remained marked in queue") + } + queue.Stop() +} + +func TestStopTerminatesWorkersAndRejectsTasks(t *testing.T) { + queue := InitQueue(1, nil) + worker := &testWorker{} + queue.StartWorkers(worker) + if err := queue.Push(Task{ID: "task"}); err != nil { + t.Fatal(err) + } + + deadline := time.Now().Add(time.Second) + for worker.count.Load() == 0 && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + queue.Stop() + if err := queue.Push(Task{ID: "after-stop"}); err == nil { + t.Fatal("expected stopped queue error") + } +} From c34cb544a95c4186d2619c881d003af4490a4532 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Wed, 15 Jul 2026 23:11:15 +0530 Subject: [PATCH 016/149] fix(scheduler): synchronize tasks and shutdown Signed-off-by: vibhatsu --- pkg/scheduler/scheduler.go | 96 ++++++++++++++++++++++++++------- pkg/scheduler/scheduler_test.go | 60 +++++++++++++++++++++ 2 files changed, 138 insertions(+), 18 deletions(-) create mode 100644 pkg/scheduler/scheduler_test.go diff --git a/pkg/scheduler/scheduler.go b/pkg/scheduler/scheduler.go index 6e46558c..97c6d7cb 100644 --- a/pkg/scheduler/scheduler.go +++ b/pkg/scheduler/scheduler.go @@ -1,42 +1,62 @@ package scheduler import ( + "fmt" "sync" "time" + + log "github.com/sirupsen/logrus" ) type Scheduler struct { Tasks TaskMap FuncRegister TaskFunctionRegister - stopChan chan bool - ticker *time.Ticker + stopChan chan struct{} + done chan struct{} + interval time.Duration - once sync.Once + once sync.Once + mu sync.Mutex + started bool + stopped bool + running map[TaskID]bool + taskRuns sync.WaitGroup } func NewScheduler() Scheduler { + return newScheduler(time.Second) +} + +func newScheduler(interval time.Duration) Scheduler { return Scheduler{ Tasks: NewTaskMap(), FuncRegister: NewTaskFunctionRegister(), - - stopChan: make(chan bool), - ticker: time.NewTicker(1 * time.Second), - - once: sync.Once{}, + stopChan: make(chan struct{}), + done: make(chan struct{}), + interval: interval, + running: make(map[TaskID]bool), } } func (scheduler *Scheduler) Start() { + scheduler.mu.Lock() + if scheduler.started || scheduler.stopped { + scheduler.mu.Unlock() + return + } + scheduler.started = true + scheduler.mu.Unlock() + go func() { + ticker := time.NewTicker(scheduler.interval) + defer ticker.Stop() + defer close(scheduler.done) for { select { - case <-scheduler.ticker.C: + case <-ticker.C: scheduler.runPending() case <-scheduler.stopChan: - scheduler.ticker.Stop() - close(scheduler.stopChan) - return } } @@ -45,15 +65,29 @@ func (scheduler *Scheduler) Start() { func (Scheduler *Scheduler) Stop() { Scheduler.once.Do(func() { - Scheduler.stopChan <- true + Scheduler.mu.Lock() + Scheduler.stopped = true + started := Scheduler.started + close(Scheduler.stopChan) + if !started { + close(Scheduler.done) + } + Scheduler.mu.Unlock() }) + <-Scheduler.done + Scheduler.taskRuns.Wait() } func (Scheduler *Scheduler) Wait() { - <-Scheduler.stopChan + <-Scheduler.done } func (scheduler *Scheduler) ScheduleAt(time time.Time, function Function, params ...FuncParam) error { + scheduler.mu.Lock() + defer scheduler.mu.Unlock() + if scheduler.stopped { + return fmt.Errorf("scheduler is stopped") + } funcID, err := scheduler.FuncRegister.AddFunction(function, params...) if err != nil { return err @@ -73,6 +107,14 @@ func (scheduler *Scheduler) ScheduleAfter(duration time.Duration, function Funct } func (scheduler *Scheduler) ScheduleEvery(duration time.Duration, function Function, params ...FuncParam) error { + if duration <= 0 { + return fmt.Errorf("schedule duration must be positive") + } + scheduler.mu.Lock() + defer scheduler.mu.Unlock() + if scheduler.stopped { + return fmt.Errorf("scheduler is stopped") + } funcID, err := scheduler.FuncRegister.AddFunction(function, params...) if err != nil { return err @@ -89,18 +131,36 @@ func (scheduler *Scheduler) ScheduleEvery(duration time.Duration, function Funct } func (scheduler *Scheduler) runPending() { + scheduler.mu.Lock() + now := time.Now() for id, task := range scheduler.Tasks { - if task.IsDue() { + if task.IsDue() && !scheduler.running[id] { if function, ok := scheduler.FuncRegister.Functions[task.FunctionID]; ok { - go function.Run() + scheduler.running[id] = true + scheduler.taskRuns.Add(1) + go scheduler.runTask(id, function) } if !task.Schedule.IsRecurring { delete(scheduler.Tasks, id) } else { - task.Schedule.LastRun = time.Now() - task.Schedule.NextRun = task.Schedule.NextRun.Add(task.Schedule.Duration) + task.Schedule.LastRun = now + task.Schedule.NextRun = now.Add(task.Schedule.Duration) } } } + scheduler.mu.Unlock() +} + +func (scheduler *Scheduler) runTask(id TaskID, function TaskFunction) { + defer scheduler.taskRuns.Done() + defer func() { + if recovered := recover(); recovered != nil { + log.Errorf("scheduled task panicked: %v", recovered) + } + scheduler.mu.Lock() + delete(scheduler.running, id) + scheduler.mu.Unlock() + }() + function.Run() } diff --git a/pkg/scheduler/scheduler_test.go b/pkg/scheduler/scheduler_test.go new file mode 100644 index 00000000..2e9d926f --- /dev/null +++ b/pkg/scheduler/scheduler_test.go @@ -0,0 +1,60 @@ +package scheduler + +import ( + "sync" + "sync/atomic" + "testing" + "time" +) + +func TestSchedulerRejectsNonPositiveRecurringDuration(t *testing.T) { + scheduler := newScheduler(time.Millisecond) + if err := scheduler.ScheduleEvery(0, func() {}); err == nil { + t.Fatal("expected invalid duration error") + } + scheduler.Stop() +} + +func TestSchedulerDoesNotOverlapRecurringTask(t *testing.T) { + scheduler := newScheduler(time.Millisecond) + var running atomic.Int32 + var maximum atomic.Int32 + task := func() { + current := running.Add(1) + for { + old := maximum.Load() + if current <= old || maximum.CompareAndSwap(old, current) { + break + } + } + time.Sleep(10 * time.Millisecond) + running.Add(-1) + } + if err := scheduler.ScheduleEvery(time.Millisecond, task); err != nil { + t.Fatal(err) + } + scheduler.Start() + time.Sleep(30 * time.Millisecond) + scheduler.Stop() + if maximum.Load() != 1 { + t.Fatalf("maximum concurrent executions = %d, want 1", maximum.Load()) + } +} + +func TestSchedulerSupportsConcurrentScheduling(t *testing.T) { + scheduler := newScheduler(time.Millisecond) + scheduler.Start() + var group sync.WaitGroup + for i := 0; i < 20; i++ { + group.Add(1) + go func() { + defer group.Done() + if err := scheduler.ScheduleAfter(time.Millisecond, func() {}); err != nil { + t.Errorf("schedule task: %v", err) + } + }() + } + group.Wait() + time.Sleep(5 * time.Millisecond) + scheduler.Stop() +} From fb16caf02189c42bcfd7f702763b03a5d1099209 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Wed, 15 Jul 2026 23:12:15 +0530 Subject: [PATCH 017/149] fix(controller): enforce a single active process Signed-off-by: vibhatsu --- cmd/beast/run.go | 40 ++++++++++++++++++++++++++++++++++++++++ cmd/beast/run_test.go | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 cmd/beast/run_test.go diff --git a/cmd/beast/run.go b/cmd/beast/run.go index a582c0d5..7c0832fa 100644 --- a/cmd/beast/run.go +++ b/cmd/beast/run.go @@ -8,6 +8,7 @@ import ( "os" "os/signal" "path/filepath" + "strconv" "syscall" "github.com/sdslabs/beastv4/core" @@ -21,6 +22,39 @@ import ( "github.com/spf13/cobra" ) +const controllerLockFile = "controller.lock" + +func acquireControllerLock(directory string) (*os.File, error) { + path := filepath.Join(directory, controllerLockFile) + file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0600) + if err != nil { + return nil, fmt.Errorf("open controller lock: %w", err) + } + if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + file.Close() + return nil, fmt.Errorf("another Beast controller is already active: %w", err) + } + if err := file.Truncate(0); err != nil { + syscall.Flock(int(file.Fd()), syscall.LOCK_UN) + file.Close() + return nil, fmt.Errorf("truncate controller lock: %w", err) + } + if _, err := file.WriteString(strconv.Itoa(os.Getpid()) + "\n"); err != nil { + syscall.Flock(int(file.Fd()), syscall.LOCK_UN) + file.Close() + return nil, fmt.Errorf("write controller lock: %w", err) + } + return file, nil +} + +func releaseControllerLock(file *os.File) { + if file == nil { + return + } + _ = syscall.Flock(int(file.Fd()), syscall.LOCK_UN) + _ = file.Close() +} + var ( BEAST_GRAPH_CACHE = filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_CACHE_DIR, core.BEAST_GRAPH_CACHE) BEAST_LEADERBOARD_CACHE = filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_CACHE_DIR, core.BEAST_LEADERBOARD_CACHE) @@ -190,6 +224,12 @@ var runCmd = &cobra.Command{ log.Infoln("beast bootsteps complete... starting beast server") } + controllerLock, err := acquireControllerLock(core.BEAST_GLOBAL_DIR) + if err != nil { + log.Error(err) + return + } + defer releaseControllerLock(controllerLock) sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) diff --git a/cmd/beast/run_test.go b/cmd/beast/run_test.go new file mode 100644 index 00000000..888086e7 --- /dev/null +++ b/cmd/beast/run_test.go @@ -0,0 +1,39 @@ +package main + +import ( + "strings" + "testing" +) + +func TestAcquireControllerLockRejectsSecondController(t *testing.T) { + directory := t.TempDir() + first, err := acquireControllerLock(directory) + if err != nil { + t.Fatal(err) + } + defer releaseControllerLock(first) + + second, err := acquireControllerLock(directory) + if second != nil { + releaseControllerLock(second) + t.Fatal("second controller acquired lock") + } + if err == nil || !strings.Contains(err.Error(), "already active") { + t.Fatalf("expected active controller error, got %v", err) + } +} + +func TestControllerLockCanBeReacquiredAfterRelease(t *testing.T) { + directory := t.TempDir() + first, err := acquireControllerLock(directory) + if err != nil { + t.Fatal(err) + } + releaseControllerLock(first) + + second, err := acquireControllerLock(directory) + if err != nil { + t.Fatal(err) + } + releaseControllerLock(second) +} From 81e8d3ff6521390bb536b6b1191ee6262cdc7adf Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Wed, 15 Jul 2026 23:15:22 +0530 Subject: [PATCH 018/149] fix(server): drain HTTP requests before teardown Signed-off-by: vibhatsu --- api/main.go | 62 +++++++++++++++++++++++++++++++++++++++++++----- api/main_test.go | 31 ++++++++++++++++++++++++ cmd/beast/run.go | 23 +++++++++++++----- 3 files changed, 104 insertions(+), 12 deletions(-) create mode 100644 api/main_test.go diff --git a/api/main.go b/api/main.go index 37c932bb..258ec3fd 100644 --- a/api/main.go +++ b/api/main.go @@ -1,7 +1,12 @@ package api import ( + "context" + "errors" + "fmt" "net/http" + "strconv" + "time" "github.com/gin-gonic/gin" "github.com/sdslabs/beastv4/core/cache" @@ -23,6 +28,7 @@ import ( const ( DEFAULT_BEAST_PORT = ":5005" + shutdownTimeout = 30 * time.Second ) var BeastScheduler scheduler.Scheduler = scheduler.NewScheduler() @@ -51,15 +57,36 @@ func runBeastApiBootsteps(defaultauthorpassword string) error { // @in header // @name Authorization -func RunBeastApiServer(port, defaultauthorpassword string, autoDeploy, healthProbe, periodicSync bool, noCache bool) { +func listenAddress(port string) (string, error) { + if port == "" { + return DEFAULT_BEAST_PORT, nil + } + value, err := strconv.Atoi(port) + if err != nil || value < 1 || value > 65535 { + return "", fmt.Errorf("invalid API port %q", port) + } + return ":" + strconv.Itoa(value), nil +} + +func newHTTPServer(address string, handler http.Handler) *http.Server { + return &http.Server{ + Addr: address, + Handler: handler, + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + IdleTimeout: 2 * time.Minute, + MaxHeaderBytes: 64 << 10, + } +} + +func RunBeastApiServer(ctx context.Context, port, defaultauthorpassword string, autoDeploy, healthProbe, periodicSync bool, noCache bool) error { log.Info("Bootstrapping Beast API server") config.InitConfig() - if port != "" { - port = ":" + port - } else { - port = DEFAULT_BEAST_PORT + address, err := listenAddress(port) + if err != nil { + return err } manager.Q = wpool.InitQueue(core.MAX_QUEUE_SIZE, nil) @@ -117,5 +144,28 @@ func RunBeastApiServer(port, defaultauthorpassword string, autoDeploy, healthPro if noCache { log.Infof("Starting Beast server in no cache mode") } - router.Run(port) + server := newHTTPServer(address, router) + serverErr := make(chan error, 1) + go func() { + serverErr <- server.ListenAndServe() + }() + + select { + case err := <-serverErr: + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return fmt.Errorf("serve Beast API: %w", err) + case <-ctx.Done(): + shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() + if err := server.Shutdown(shutdownCtx); err != nil { + return fmt.Errorf("shut down Beast API: %w", err) + } + err := <-serverErr + if err != nil && !errors.Is(err, http.ErrServerClosed) { + return fmt.Errorf("serve Beast API: %w", err) + } + return nil + } } diff --git a/api/main_test.go b/api/main_test.go new file mode 100644 index 00000000..7fab3438 --- /dev/null +++ b/api/main_test.go @@ -0,0 +1,31 @@ +package api + +import ( + "net/http" + "testing" + "time" +) + +func TestListenAddressValidatesPort(t *testing.T) { + for _, port := range []string{"0", "65536", "not-a-port", "5005; shutdown"} { + if _, err := listenAddress(port); err == nil { + t.Fatalf("expected invalid port %q to fail", port) + } + } + if address, err := listenAddress("5005"); err != nil || address != ":5005" { + t.Fatalf("listenAddress returned %q, %v", address, err) + } +} + +func TestHTTPServerHasDefensiveTimeouts(t *testing.T) { + server := newHTTPServer(":5005", http.NewServeMux()) + if server.ReadHeaderTimeout <= 0 || server.ReadTimeout <= 0 || server.IdleTimeout <= 0 { + t.Fatalf("missing HTTP timeouts: %+v", server) + } + if server.MaxHeaderBytes <= 0 || server.MaxHeaderBytes > 64<<10 { + t.Fatalf("unexpected max header bytes: %d", server.MaxHeaderBytes) + } + if server.ReadHeaderTimeout > 30*time.Second { + t.Fatalf("read header timeout is too permissive: %v", server.ReadHeaderTimeout) + } +} diff --git a/cmd/beast/run.go b/cmd/beast/run.go index 7c0832fa..1eee2aaf 100644 --- a/cmd/beast/run.go +++ b/cmd/beast/run.go @@ -1,6 +1,7 @@ package main import ( + "context" "encoding/json" "fmt" "github.com/sdslabs/beastv4/core/cache" @@ -224,6 +225,13 @@ var runCmd = &cobra.Command{ log.Infoln("beast bootsteps complete... starting beast server") } + if Port != "" { + port, err := strconv.Atoi(Port) + if err != nil || port < 1 || port > 65535 { + log.Errorf("invalid API port %q", Port) + return + } + } controllerLock, err := acquireControllerLock(core.BEAST_GLOBAL_DIR) if err != nil { log.Error(err) @@ -231,13 +239,16 @@ var runCmd = &cobra.Command{ } defer releaseControllerLock(controllerLock) - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) - - go api.RunBeastApiServer(Port, DefaultAuthorPassword, AutoDeploy, HealthProbe, PeriodicSync, NoCache) - <-sigChan + ctx, stopSignals := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stopSignals() - log.Infoln("\nShutdown signal received.") + err = api.RunBeastApiServer(ctx, Port, DefaultAuthorPassword, AutoDeploy, HealthProbe, PeriodicSync, NoCache) + if err != nil { + log.Errorf("Beast API stopped: %v", err) + } + if ctx.Err() != nil { + log.Infoln("Shutdown signal received.") + } cleanup() log.Infoln("Server stopped gracefully.") }, From 0f0ca640c17ed579aa9391cfd5421544f815c79a Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Wed, 15 Jul 2026 23:16:59 +0530 Subject: [PATCH 019/149] fix(scoring): stop background worker on shutdown Signed-off-by: vibhatsu --- api/main.go | 7 ++++++- api/submit.go | 38 ++++++++++++++++++++------------------ 2 files changed, 26 insertions(+), 19 deletions(-) diff --git a/api/main.go b/api/main.go index 258ec3fd..436ba695 100644 --- a/api/main.go +++ b/api/main.go @@ -96,7 +96,12 @@ func RunBeastApiServer(ctx context.Context, port, defaultauthorpassword string, remoteManager.Init() database.Init() cache.Init() - startDynamicScoreWorker() + backgroundCtx, stopBackground := context.WithCancel(ctx) + dynamicScoreDone := startDynamicScoreWorker(backgroundCtx) + defer func() { + stopBackground() + <-dynamicScoreDone + }() go manager.InstanceCleanupProber() // Initialise and start the Hub diff --git a/api/submit.go b/api/submit.go index c08b4373..6c412b98 100644 --- a/api/submit.go +++ b/api/submit.go @@ -1,10 +1,10 @@ package api import ( + "context" "math" "net/http" "strconv" - "sync" "time" "github.com/gin-gonic/gin" @@ -17,8 +17,7 @@ import ( ) var ( - dynamicScoreWorkerOnce sync.Once - dynamicScoreNotify = make(chan struct{}, 1) + dynamicScoreNotify = make(chan struct{}, 1) ) // Verifies and creates an entry in the database for successful submission of flag for a challenge. @@ -269,22 +268,25 @@ func dynamicScore(maxPoints, minPoints, solvers uint) uint { return uint(math.Round(float64(minPoints) + (float64(maxPoints)-float64(minPoints))/divisor)) } -func startDynamicScoreWorker() { - dynamicScoreWorkerOnce.Do(func() { - go func() { - ticker := time.NewTicker(30 * time.Second) - defer ticker.Stop() - - for { - select { - case <-dynamicScoreNotify: - processDirtyDynamicScores() - case <-ticker.C: - processDirtyDynamicScores() - } +func startDynamicScoreWorker(ctx context.Context) <-chan struct{} { + done := make(chan struct{}) + go func() { + defer close(done) + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-dynamicScoreNotify: + processDirtyDynamicScores() + case <-ticker.C: + processDirtyDynamicScores() } - }() - }) + } + }() + return done } func notifyDynamicScoreWorker() { From ccda9eae58c0681141fd874c72cc0a138fcf39b9 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Wed, 15 Jul 2026 23:20:30 +0530 Subject: [PATCH 020/149] fix(probers): stop background loops cleanly Signed-off-by: vibhatsu --- api/main.go | 21 ++++-- cmd/beast/healthprobe.go | 17 ++++- core/manager/health_check.go | 121 ++++++++++++++++++------------ core/manager/health_check_test.go | 14 ++++ 4 files changed, 120 insertions(+), 53 deletions(-) create mode 100644 core/manager/health_check_test.go diff --git a/api/main.go b/api/main.go index 436ba695..f374945f 100644 --- a/api/main.go +++ b/api/main.go @@ -98,11 +98,26 @@ func RunBeastApiServer(ctx context.Context, port, defaultauthorpassword string, cache.Init() backgroundCtx, stopBackground := context.WithCancel(ctx) dynamicScoreDone := startDynamicScoreWorker(backgroundCtx) + instanceCleanupDone := make(chan struct{}) + go func() { + defer close(instanceCleanupDone) + manager.InstanceCleanupProber(backgroundCtx) + }() + healthCheckDone := make(chan struct{}) + if healthProbe || config.Cfg.HealthProber { + go func() { + defer close(healthCheckDone) + manager.BeastHealthCheckProber(backgroundCtx, config.Cfg.TickerFrequency) + }() + } else { + close(healthCheckDone) + } defer func() { stopBackground() <-dynamicScoreDone + <-instanceCleanupDone + <-healthCheckDone }() - go manager.InstanceCleanupProber() // Initialise and start the Hub // Must be started before the Notification Router, since SSE handler has access to SSE Hub @@ -138,10 +153,6 @@ func RunBeastApiServer(ctx context.Context, port, defaultauthorpassword string, BeastScheduler.ScheduleEvery(config.Cfg.RemoteSyncPeriod, manager.AutoUpdate) } - if healthProbe || config.Cfg.HealthProber { - go manager.BeastHeathCheckProber(config.Cfg.TickerFrequency) - } - if autoDeploy { manager.InitialAutoDeploy() } diff --git a/cmd/beast/healthprobe.go b/cmd/beast/healthprobe.go index 65bc5691..dd319906 100644 --- a/cmd/beast/healthprobe.go +++ b/cmd/beast/healthprobe.go @@ -1,8 +1,15 @@ package main import ( + "context" + "os" + "os/signal" + "syscall" + + "github.com/sdslabs/beastv4/core" "github.com/sdslabs/beastv4/core/config" "github.com/sdslabs/beastv4/core/manager" + log "github.com/sirupsen/logrus" "github.com/spf13/cobra" ) @@ -13,7 +20,15 @@ var healthProbeCmd = &cobra.Command{ Run: func(cmd *cobra.Command, args []string) { config.InitConfig() + controllerLock, err := acquireControllerLock(core.BEAST_GLOBAL_DIR) + if err != nil { + log.Error(err) + return + } + defer releaseControllerLock(controllerLock) - go manager.BeastHeathCheckProber(config.Cfg.TickerFrequency) + ctx, stopSignals := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stopSignals() + manager.BeastHealthCheckProber(ctx, config.Cfg.TickerFrequency) }, } diff --git a/core/manager/health_check.go b/core/manager/health_check.go index 37cfeac2..6a52aa3f 100644 --- a/core/manager/health_check.go +++ b/core/manager/health_check.go @@ -2,6 +2,7 @@ package manager import ( "context" + "errors" "fmt" "path/filepath" "strings" @@ -20,11 +21,7 @@ import ( log "github.com/sirupsen/logrus" ) -var HEALTH_CHECKER = false -var ( - instanceCleanupOnce sync.Once - instanceExpirySubscriberOnce sync.Once -) +var instanceDeletionMutex sync.Mutex // Check for static challenegs' assets to be present on staging server. // At the time of writing, Beast deploys assets to localhost only. @@ -158,66 +155,94 @@ func ServerHealthProber(waitTime int) { } } -func BeastHeathCheckProber(waitTime int) { - if !HEALTH_CHECKER { - log.Info("Starting Health Check prober.") - HEALTH_CHECKER = true - - go InstanceCleanupProber() +func runHealthCheckCycle(waitTime int) { + var group sync.WaitGroup + checks := []func(){ + func() { ChallengesHealthProber(waitTime) }, + func() { ServerHealthProber(waitTime) }, + func() { _ = database.BackupDatabase() }, + func() { _ = cache.BackupCache() }, + } + group.Add(len(checks)) + for _, check := range checks { + go func(check func()) { + defer group.Done() + check() + }(check) + } + group.Wait() +} - for { - go ChallengesHealthProber(waitTime) - go ServerHealthProber(waitTime) - go database.BackupDatabase() - go cache.BackupCache() - time.Sleep(time.Duration(waitTime) * time.Second) +func BeastHealthCheckProber(ctx context.Context, waitTime int) { + if waitTime <= 0 { + log.Error("Health check interval must be positive") + return + } + if ctx.Err() != nil { + return + } + log.Info("Starting Health Check prober.") + ticker := time.NewTicker(time.Duration(waitTime) * time.Second) + defer ticker.Stop() + for { + if ctx.Err() != nil { + return + } + runHealthCheckCycle(waitTime) + select { + case <-ctx.Done(): + return + case <-ticker.C: } - } else { - log.Warn("Health Checker Already Running. Not Starting Again") } } -func InstanceCleanupProber() { - started := false - instanceCleanupOnce.Do(func() { - started = true - }) - if !started { - log.Warn("Instance cleanup prober already running. Not starting again") +func InstanceCleanupProber(ctx context.Context) { + if ctx.Err() != nil { return } - log.Info("Starting Instance Cleanup prober with event-driven expiry and reconciliation interval: ", core.DEFAULT_HEALTH_CHECK_TIME) - startInstanceExpirySubscriber() + subscriberDone := startInstanceExpirySubscriber(ctx) + defer func() { <-subscriberDone }() + ticker := time.NewTicker(core.DEFAULT_HEALTH_CHECK_TIME) + defer ticker.Stop() for { + if ctx.Err() != nil { + return + } ProcessInstanceDeletionQueue() CleanupOrphanedInstanceContainers() QueueExpiredInstances() - time.Sleep(core.DEFAULT_HEALTH_CHECK_TIME) + select { + case <-ctx.Done(): + return + case <-ticker.C: + } } } -func startInstanceExpirySubscriber() { - instanceExpirySubscriberOnce.Do(func() { - if err := cache.EnableKeyspaceExpiryNotifications(); err != nil { - log.Warnf("Redis keyspace expiry notifications unavailable, relying on reconciliation: %v", err) - } +func startInstanceExpirySubscriber(ctx context.Context) <-chan struct{} { + done := make(chan struct{}) + if err := cache.EnableKeyspaceExpiryNotifications(); err != nil { + log.Warnf("Redis keyspace expiry notifications unavailable, relying on reconciliation: %v", err) + } - go func() { - err := cache.SubscribeExpiredInstanceMarkers(context.Background(), func(instanceID string) { - log.Infof("Instance expiry marker fired for %s, queueing cleanup", instanceID) - if err := cache.QueueInstanceForDeletion(instanceID); err != nil { - log.Warnf("Failed to queue expired instance %s from Redis event: %v", instanceID, err) - return - } - ProcessInstanceDeletionQueue() - }) - if err != nil { - log.Warnf("Redis expiry subscriber stopped, reconciliation will continue cleanup: %v", err) + go func() { + defer close(done) + err := cache.SubscribeExpiredInstanceMarkers(ctx, func(instanceID string) { + log.Infof("Instance expiry marker fired for %s, queueing cleanup", instanceID) + if err := cache.QueueInstanceForDeletion(instanceID); err != nil { + log.Warnf("Failed to queue expired instance %s from Redis event: %v", instanceID, err) + return } - }() - }) + ProcessInstanceDeletionQueue() + }) + if err != nil && !errors.Is(err, context.Canceled) { + log.Warnf("Redis expiry subscriber stopped, reconciliation will continue cleanup: %v", err) + } + }() + return done } func QueueExpiredInstances() { @@ -241,6 +266,8 @@ func QueueExpiredInstances() { } func ProcessInstanceDeletionQueue() { + instanceDeletionMutex.Lock() + defer instanceDeletionMutex.Unlock() log.Debug("Processing instance deletion queue") for i := 0; i < 10; i++ { diff --git a/core/manager/health_check_test.go b/core/manager/health_check_test.go new file mode 100644 index 00000000..679618bf --- /dev/null +++ b/core/manager/health_check_test.go @@ -0,0 +1,14 @@ +package manager + +import ( + "context" + "testing" +) + +func TestBackgroundProbersRespectCancelledContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + BeastHealthCheckProber(ctx, 1) + InstanceCleanupProber(ctx) +} From 83cbfa5adba11bef41fc7cfdfc8eda3502a54245 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Wed, 15 Jul 2026 23:21:43 +0530 Subject: [PATCH 021/149] fix(shutdown): stop producers before container cleanup Signed-off-by: vibhatsu --- cmd/beast/run.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/cmd/beast/run.go b/cmd/beast/run.go index 1eee2aaf..2ac206c0 100644 --- a/cmd/beast/run.go +++ b/cmd/beast/run.go @@ -70,6 +70,10 @@ func stopApiScheduler() { func stopWorkerQueue() { log.Infoln("Stopping the worker queue...") + if manager.Q == nil { + log.Infoln("Worker queue was not started") + return + } manager.Q.Stop() log.Infoln("Worker queue stopped") } @@ -188,12 +192,12 @@ func stopSseNotificationHub() { func cleanup() { log.Info("Starting graceful shutdown cleanup...") - stopSseNotificationHub() stopApiScheduler() + stopWorkerQueue() + stopSseNotificationHub() cleanupRunningContainers() - stopWorkerQueue() stopRemoteManagers() saveLeaderboardCache() From 9d0c85e7e5ea48b7a32982bd8d1df353897f6914 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Wed, 15 Jul 2026 23:24:18 +0530 Subject: [PATCH 022/149] fix(api): synchronize leaderboard caches Signed-off-by: vibhatsu --- api/admin.go | 3 +-- api/auth.go | 6 +----- api/info.go | 37 +++++++++++++++++++++---------------- api/manage.go | 8 ++------ api/submit.go | 20 ++++---------------- 5 files changed, 29 insertions(+), 45 deletions(-) diff --git a/api/admin.go b/api/admin.go index df0cb6ec..20d9c3f9 100644 --- a/api/admin.go +++ b/api/admin.go @@ -69,8 +69,7 @@ func userActionHandler(c *gin.Context) { return } if val, _ := database.IsFrozenScoreSet(); !val { - leaderboardStale = true - graphCacheStale = true + markLeaderboardCachesStale() } c.JSON(http.StatusOK, HTTPPlainResp{ Message: fmt.Sprintf("Successfully %sned the user with id %s", action, userId), diff --git a/api/auth.go b/api/auth.go index a50194d0..82ceb4e4 100644 --- a/api/auth.go +++ b/api/auth.go @@ -273,11 +273,7 @@ func register(c *gin.Context) { return } - if len(adminLeaderboardCache) < core.LEADERBOARD_SIZE { - leaderboardStale = true - graphCacheStale = true - adminLeaderboardStale = true - } + markLeaderboardCachesStale() c.JSON(http.StatusOK, HTTPPlainResp{ Message: "User created successfully", diff --git a/api/info.go b/api/info.go index 175a52f9..4f0c1f31 100644 --- a/api/info.go +++ b/api/info.go @@ -7,6 +7,7 @@ import ( "sort" "strconv" "strings" + "sync" "github.com/gin-gonic/gin" "github.com/sdslabs/beastv4/core" @@ -27,8 +28,17 @@ var ( adminLeaderboardStale = true graphCache []database.UserLeaderboardResp graphCacheStale = true + leaderboardCacheMu sync.Mutex ) +func markLeaderboardCachesStale() { + leaderboardCacheMu.Lock() + leaderboardStale = true + adminLeaderboardStale = true + graphCacheStale = true + leaderboardCacheMu.Unlock() +} + func hintHandler(c *gin.Context) { hintIDStr := c.Param("hintID") @@ -140,18 +150,7 @@ func hintHandler(c *gin.Context) { return } - oldScore := user.Score - newScore := oldScore - hint.Points - if newScore < 0 { - newScore = 0 - } - - if len(adminLeaderboardCache) < core.LEADERBOARD_SIZE || - (len(adminLeaderboardCache) > 0 && oldScore >= adminLeaderboardCache[len(adminLeaderboardCache)-1].Score) { - leaderboardStale = true - graphCacheStale = true - adminLeaderboardStale = true - } + markLeaderboardCachesStale() // Return the hint description after successfully taking it c.JSON(http.StatusOK, HTTPPlainResp{ @@ -992,6 +991,8 @@ func getLeaderboardHandler(c *gin.Context) { } if isLeaderboardFrozen { if page == 1 { + leaderboardCacheMu.Lock() + defer leaderboardCacheMu.Unlock() if leaderboardStale { users, err := database.QueryTopUsersByFrozenScore(core.LEADERBOARD_SIZE) if err != nil { @@ -1050,6 +1051,8 @@ func getLeaderboardHandler(c *gin.Context) { } if page == 1 { + leaderboardCacheMu.Lock() + defer leaderboardCacheMu.Unlock() if leaderboardStale { users, err := database.QueryTopUsersByScore(core.LEADERBOARD_SIZE) if err != nil { @@ -1132,6 +1135,8 @@ func adminLeaderboardHandler(c *gin.Context) { return } if page == 1 { + leaderboardCacheMu.Lock() + defer leaderboardCacheMu.Unlock() if adminLeaderboardStale { users, err := database.QueryTopUsersByScore(core.LEADERBOARD_SIZE) if err != nil { @@ -1209,8 +1214,7 @@ func freezeLeaderboardHandler(c *gin.Context) { Message: "DATABASE ERROR while processing the request.", }) } - leaderboardStale = true - graphCacheStale = true + markLeaderboardCachesStale() c.JSON(http.StatusOK, HTTPPlainResp{ Message: "User leaderboard frozen successfully", }) @@ -1235,8 +1239,7 @@ func unfreezeLeaderboardHandler(c *gin.Context) { Message: "DATABASE ERROR while processing the request.", }) } - leaderboardStale = true - graphCacheStale = true + markLeaderboardCachesStale() c.JSON(http.StatusOK, HTTPPlainResp{ Message: "User leaderboard unfrozen successfully", }) @@ -1443,6 +1446,8 @@ func getUserAttempts(c *gin.Context) { } func getLeaderboardGraphHandler(c *gin.Context) { + leaderboardCacheMu.Lock() + defer leaderboardCacheMu.Unlock() var topUsers []uint // TODO: Add a check for leaderboard stale to prevent stale graphs // Try if graphCache and leaderboardCache can be merged. diff --git a/api/manage.go b/api/manage.go index af58048b..225075a6 100644 --- a/api/manage.go +++ b/api/manage.go @@ -120,9 +120,7 @@ func manageChallengeHandler(c *gin.Context) { } if action == core.MANAGE_ACTION_PURGE { - leaderboardStale = true - graphCacheStale = true - adminLeaderboardStale = true + markLeaderboardCachesStale() } respStr := fmt.Sprintf("Your action %s on challenge %s has been triggered, check stats.", action, identifier) @@ -183,9 +181,7 @@ func manageMultipleChallengeHandlerNameBased(c *gin.Context) { } if action == core.MANAGE_ACTION_PURGE { - leaderboardStale = true - graphCacheStale = true - adminLeaderboardStale = true + markLeaderboardCachesStale() } c.JSON(http.StatusOK, HTTPPlainMapResp{ diff --git a/api/submit.go b/api/submit.go index 6c412b98..670d6b3f 100644 --- a/api/submit.go +++ b/api/submit.go @@ -249,9 +249,7 @@ func submitFlagHandler(c *gin.Context) { } } - leaderboardStale = true - graphCacheStale = true - adminLeaderboardStale = true + markLeaderboardCachesStale() c.JSON(http.StatusOK, FlagSubmitResp{ Message: "Your flag is correct", @@ -341,9 +339,7 @@ func recomputeDynamicScore(dirty database.DynamicScoreDirty) error { if delta != 0 { log.Debugf("By dynamic scoring the points of challenge %s are changed to %d from %d", challenge.Name, newPoints, challenge.Points) - leaderboardStale = true - graphCacheStale = true - adminLeaderboardStale = true + markLeaderboardCachesStale() } return nil @@ -358,7 +354,6 @@ func updatePointsOfSolvers(submissions []database.UserChallenges, newChallengePo return err } if user.Role == "contestant" { - oldScore := user.Score newScore := user.Score + (newChallengePointsAfterSolve - oldChallengePointsBeforeSolve) if newScore <= 0 { newScore = 0 @@ -367,19 +362,12 @@ func updatePointsOfSolvers(submissions []database.UserChallenges, newChallengePo if err != nil { return err } - // Check if this user's score change could affect top 25 leaderboard - if !scoreChanged && (len(adminLeaderboardCache) < core.LEADERBOARD_SIZE || - (len(adminLeaderboardCache) > 0 && (oldScore >= adminLeaderboardCache[len(adminLeaderboardCache)-1].Score || - newScore >= adminLeaderboardCache[len(adminLeaderboardCache)-1].Score))) { - scoreChanged = true - } + scoreChanged = true } } // Mark cache stale if any user's score change could affect top 25 if scoreChanged { - leaderboardStale = true - graphCacheStale = true - adminLeaderboardStale = true + markLeaderboardCachesStale() } return nil } From 9c24ac3d9e72aa5db400b83896d249716802cb8e Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Wed, 15 Jul 2026 23:25:19 +0530 Subject: [PATCH 023/149] fix(scoring): prevent unsigned score underflow Signed-off-by: vibhatsu --- api/submit.go | 13 +++++++++---- api/submit_test.go | 22 ++++++++++++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) create mode 100644 api/submit_test.go diff --git a/api/submit.go b/api/submit.go index 670d6b3f..e977438e 100644 --- a/api/submit.go +++ b/api/submit.go @@ -266,6 +266,14 @@ func dynamicScore(maxPoints, minPoints, solvers uint) uint { return uint(math.Round(float64(minPoints) + (float64(maxPoints)-float64(minPoints))/divisor)) } +func scoreAfterPointChange(currentScore, newPoints, oldPoints uint) uint { + score := int64(currentScore) + int64(newPoints) - int64(oldPoints) + if score < 0 { + return 0 + } + return uint(score) +} + func startDynamicScoreWorker(ctx context.Context) <-chan struct{} { done := make(chan struct{}) go func() { @@ -354,10 +362,7 @@ func updatePointsOfSolvers(submissions []database.UserChallenges, newChallengePo return err } if user.Role == "contestant" { - newScore := user.Score + (newChallengePointsAfterSolve - oldChallengePointsBeforeSolve) - if newScore <= 0 { - newScore = 0 - } + newScore := scoreAfterPointChange(user.Score, newChallengePointsAfterSolve, oldChallengePointsBeforeSolve) err = database.UpdateUser(&user, map[string]interface{}{"Score": newScore}) if err != nil { return err diff --git a/api/submit_test.go b/api/submit_test.go new file mode 100644 index 00000000..9aecdb3d --- /dev/null +++ b/api/submit_test.go @@ -0,0 +1,22 @@ +package api + +import "testing" + +func TestScoreAfterPointChangeDoesNotUnderflow(t *testing.T) { + tests := []struct { + name string + current, new, old uint + want uint + }{ + {name: "increase", current: 100, new: 75, old: 50, want: 125}, + {name: "decrease", current: 100, new: 25, old: 50, want: 75}, + {name: "clamp at zero", current: 10, new: 0, old: 50, want: 0}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := scoreAfterPointChange(test.current, test.new, test.old); got != test.want { + t.Fatalf("scoreAfterPointChange() = %d, want %d", got, test.want) + } + }) + } +} From 08313ed50392ee9ab380c9813c432b82aad31220 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Wed, 15 Jul 2026 23:26:47 +0530 Subject: [PATCH 024/149] fix(resources): enforce operator container ceilings Signed-off-by: vibhatsu --- core/config/challenge.go | 22 ++++++++++++++++++++-- core/config/utils_test.go | 32 ++++++++++++++++++++++++++++++++ core/manager/instance.go | 4 +++- 3 files changed, 55 insertions(+), 3 deletions(-) diff --git a/core/config/challenge.go b/core/config/challenge.go index dc37cd41..7ede17d7 100644 --- a/core/config/challenge.go +++ b/core/config/challenge.go @@ -72,7 +72,9 @@ func (config *BeastChallengeConfig) ValidateRequiredFields(challdir string) erro return err } - config.Resources.ValidateRequiredFields() + if err = config.Resources.ValidateRequiredFields(); err != nil { + return err + } for _, maintainer := range config.Maintainers { err = maintainer.ValidateRequiredFields() @@ -540,7 +542,10 @@ type Resources struct { CPUsLimit float32 `toml:"cpuslimit"` } -func (config *Resources) ValidateRequiredFields() { +func (config *Resources) ValidateRequiredFields() error { + if Cfg == nil { + return errors.New("global configuration is not initialized") + } if config.CPUShares <= 0 { log.Debug("CPU shares not provided in configuration, using default.") config.CPUShares = Cfg.CPUShares @@ -560,4 +565,17 @@ func (config *Resources) ValidateRequiredFields() { log.Debug("CPUsLimit not provided in configuration, using default.") config.CPUsLimit = Cfg.CPUsLimit } + if config.CPUShares > Cfg.CPUShares { + return fmt.Errorf("cpu_shares %d exceeds global limit %d", config.CPUShares, Cfg.CPUShares) + } + if config.Memory > Cfg.Memory { + return fmt.Errorf("memory_limit %d exceeds global limit %d", config.Memory, Cfg.Memory) + } + if config.PidsLimit > Cfg.PidsLimit { + return fmt.Errorf("pids_limit %d exceeds global limit %d", config.PidsLimit, Cfg.PidsLimit) + } + if config.CPUsLimit > Cfg.CPUsLimit { + return fmt.Errorf("cpuslimit %.2f exceeds global limit %.2f", config.CPUsLimit, Cfg.CPUsLimit) + } + return nil } diff --git a/core/config/utils_test.go b/core/config/utils_test.go index 06924c12..d5697867 100644 --- a/core/config/utils_test.go +++ b/core/config/utils_test.go @@ -115,3 +115,35 @@ func TestLoadBeastConfigRejectsSymlink(t *testing.T) { t.Fatalf("expected symlink error, got %v", err) } } + +func TestResourcesCannotExceedGlobalLimits(t *testing.T) { + previous := Cfg + Cfg = &BeastConfig{CPUShares: 100, Memory: 1024, PidsLimit: 10, CPUsLimit: 1} + defer func() { Cfg = previous }() + + tests := []Resources{ + {CPUShares: 101}, + {Memory: 1025}, + {PidsLimit: 11}, + {CPUsLimit: 1.1}, + } + for _, resources := range tests { + if err := resources.ValidateRequiredFields(); err == nil { + t.Fatalf("expected global limit error for %+v", resources) + } + } +} + +func TestResourcesUseGlobalDefaults(t *testing.T) { + previous := Cfg + Cfg = &BeastConfig{CPUShares: 100, Memory: 1024, PidsLimit: 10, CPUsLimit: 1} + defer func() { Cfg = previous }() + + resources := Resources{} + if err := resources.ValidateRequiredFields(); err != nil { + t.Fatal(err) + } + if resources.CPUShares != 100 || resources.Memory != 1024 || resources.PidsLimit != 10 || resources.CPUsLimit != 1 { + t.Fatalf("unexpected defaults: %+v", resources) + } +} diff --git a/core/manager/instance.go b/core/manager/instance.go index 56fb9cca..a85a8f47 100644 --- a/core/manager/instance.go +++ b/core/manager/instance.go @@ -46,7 +46,9 @@ func SpawnInstance(challengeName, userID, username string) (*cache.Instance, err return nil, fmt.Errorf("failed to load challenge config: %w", err) } - config.Resources.ValidateRequiredFields() + if err := config.Resources.ValidateRequiredFields(); err != nil { + return nil, fmt.Errorf("invalid challenge resource limits: %w", err) + } if !config.Challenge.Metadata.IsInstanced() { return nil, fmt.Errorf("challenge %s is not configured for instancing", challengeName) From eba14723cd8c53b8b31639102a9785989d329d69 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Wed, 15 Jul 2026 23:28:42 +0530 Subject: [PATCH 025/149] fix(remote): verify SSH host identities Signed-off-by: vibhatsu --- core/config/config.go | 19 +++++++++++++------ pkg/remoteManager/ssh.go | 8 ++++++-- pkg/remoteManager/ssh_test.go | 27 +++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 8 deletions(-) create mode 100644 pkg/remoteManager/ssh_test.go diff --git a/core/config/config.go b/core/config/config.go index 057faf2f..6e75d88a 100644 --- a/core/config/config.go +++ b/core/config/config.go @@ -290,12 +290,13 @@ func (config *BeastConfig) UseLocalDockerDaemon(serverName string) bool { } type AvailableServer struct { - Name string `toml:"-"` - Host string `toml:"host"` - Username string `toml:"username"` - SSHKeyPath string `toml:"ssh_key_path"` - Active bool `toml:"active"` - PortRange string `toml:"port_range"` + Name string `toml:"-"` + Host string `toml:"host"` + Username string `toml:"username"` + SSHKeyPath string `toml:"ssh_key_path"` + KnownHostsFile string `toml:"known_hosts_file"` + Active bool `toml:"active"` + PortRange string `toml:"port_range"` } func (config *AvailableServer) ValidateServerConfig() error { @@ -319,11 +320,17 @@ func (config *AvailableServer) ValidateServerConfig() error { if config.SSHKeyPath == "" { return fmt.Errorf("ssh_key_path is empty") } + if config.KnownHostsFile == "" { + config.KnownHostsFile = filepath.Join(os.Getenv("HOME"), ".ssh", "known_hosts") + } err = utils.ValidateFileExists(config.SSHKeyPath) if err != nil { return fmt.Errorf("provided ssh key file(%s) does not exists : %s", config.SSHKeyPath, err) } + if err := utils.ValidateFileExists(config.KnownHostsFile); err != nil { + return fmt.Errorf("provided known_hosts file(%s) does not exist: %s", config.KnownHostsFile, err) + } return nil } diff --git a/pkg/remoteManager/ssh.go b/pkg/remoteManager/ssh.go index 30871e53..3eaadf6d 100644 --- a/pkg/remoteManager/ssh.go +++ b/pkg/remoteManager/ssh.go @@ -9,6 +9,7 @@ import ( "github.com/sdslabs/beastv4/core/config" log "github.com/sirupsen/logrus" "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/knownhosts" ) type LoadBalancerQueue struct { @@ -100,6 +101,10 @@ func CreateSSHClient(remoteServer config.AvailableServer) (*ssh.Client, error) { if !remoteServer.Active { return nil, fmt.Errorf("server is inactive in config.toml") } + hostKeyCallback, err := knownhosts.New(remoteServer.KnownHostsFile) + if err != nil { + return nil, fmt.Errorf("load known_hosts file: %s", err) + } key, err := ioutil.ReadFile(remoteServer.SSHKeyPath) if err != nil { return nil, fmt.Errorf("unable to read private key: %s", err) @@ -109,13 +114,12 @@ func CreateSSHClient(remoteServer config.AvailableServer) (*ssh.Client, error) { if err != nil { return nil, fmt.Errorf("unable to parse private key: %s", err) } - config := &ssh.ClientConfig{ User: remoteServer.Username, Auth: []ssh.AuthMethod{ ssh.PublicKeys(signer), }, - HostKeyCallback: ssh.InsecureIgnoreHostKey(), // TODO: Insecure for now. Integrate proper callback for host key verification. + HostKeyCallback: hostKeyCallback, } client, err := ssh.Dial("tcp", fmt.Sprintf("%s:22", remoteServer.Host), config) diff --git a/pkg/remoteManager/ssh_test.go b/pkg/remoteManager/ssh_test.go new file mode 100644 index 00000000..b2cc2a80 --- /dev/null +++ b/pkg/remoteManager/ssh_test.go @@ -0,0 +1,27 @@ +package remoteManager + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/sdslabs/beastv4/core/config" +) + +func TestCreateSSHClientRejectsMissingKnownHosts(t *testing.T) { + keyFile := filepath.Join(t.TempDir(), "key") + if err := os.WriteFile(keyFile, []byte("not-a-key"), 0600); err != nil { + t.Fatal(err) + } + server := config.AvailableServer{ + Active: true, + SSHKeyPath: keyFile, + KnownHostsFile: filepath.Join(t.TempDir(), "missing"), + } + + _, err := CreateSSHClient(server) + if err == nil || !strings.Contains(err.Error(), "known_hosts") { + t.Fatalf("expected SSH configuration error, got %v", err) + } +} From 4ea50b294fd7a159c498079ab92f8a76e51ae098 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Wed, 15 Jul 2026 23:29:15 +0530 Subject: [PATCH 026/149] fix(files): validate secret file permissions Signed-off-by: vibhatsu --- utils/file.go | 17 +++++++++++++++++ utils/file_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/utils/file.go b/utils/file.go index c18c711c..a42241ae 100644 --- a/utils/file.go +++ b/utils/file.go @@ -69,6 +69,23 @@ func ValidateFileExists(filePath string) error { return nil } +func ValidateSecretFile(filePath string) error { + info, err := os.Lstat(filePath) + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("secret file must not be a symbolic link: %s", filePath) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("secret path is not a regular file: %s", filePath) + } + if info.Mode().Perm() != 0600 { + return fmt.Errorf("secret file permissions must be 0600, got %04o", info.Mode().Perm()) + } + return nil +} + // ResolvePathWithin resolves an existing relative path and verifies that it // remains inside root, including after following symbolic links. func ResolvePathWithin(root, relativePath string) (string, error) { diff --git a/utils/file_test.go b/utils/file_test.go index ed6c02ca..fc40ed88 100644 --- a/utils/file_test.go +++ b/utils/file_test.go @@ -59,3 +59,27 @@ func TestResolvePathWithinAcceptsContainedPath(t *testing.T) { t.Fatalf("resolved %q, want %q", resolved, file) } } + +func TestValidateSecretFileRequiresPrivateRegularFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "secret") + if err := os.WriteFile(path, nil, 0644); err != nil { + t.Fatal(err) + } + if err := ValidateSecretFile(path); err == nil { + t.Fatal("expected insecure permissions error") + } + if err := os.Chmod(path, 0600); err != nil { + t.Fatal(err) + } + if err := ValidateSecretFile(path); err != nil { + t.Fatal(err) + } + symlink := filepath.Join(dir, "link") + if err := os.Symlink(path, symlink); err != nil { + t.Fatal(err) + } + if err := ValidateSecretFile(symlink); err == nil { + t.Fatal("expected symlink error") + } +} From 5fc139270884a5d7e978f0516a3bbb0ed947cee6 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Wed, 15 Jul 2026 23:29:43 +0530 Subject: [PATCH 027/149] fix(config): require private SSH key files Signed-off-by: vibhatsu --- core/config/config.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/config/config.go b/core/config/config.go index 6e75d88a..dda4c83a 100644 --- a/core/config/config.go +++ b/core/config/config.go @@ -324,7 +324,7 @@ func (config *AvailableServer) ValidateServerConfig() error { config.KnownHostsFile = filepath.Join(os.Getenv("HOME"), ".ssh", "known_hosts") } - err = utils.ValidateFileExists(config.SSHKeyPath) + err = utils.ValidateSecretFile(config.SSHKeyPath) if err != nil { return fmt.Errorf("provided ssh key file(%s) does not exists : %s", config.SSHKeyPath, err) } @@ -364,7 +364,7 @@ func (config *GitRemote) ValidateGitConfig() error { config.Branch = core.GIT_REMOTE_DEFAULT_BRANCH } - err = utils.ValidateFileExists(config.Secret) + err = utils.ValidateSecretFile(config.Secret) log.Debugf("Using git ssh secret : %s", config.Secret) if err != nil { return fmt.Errorf("provided ssh key file(%s) does not exists : %s", config.Secret, err) From bf713d46cf504edc38db0d26beac7310f8e55243 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Wed, 15 Jul 2026 23:33:29 +0530 Subject: [PATCH 028/149] fix(auth): verify JWT algorithms and claims Signed-off-by: vibhatsu --- api/otp.go | 16 ++++---- core/utils/general.go | 21 ++--------- go.mod | 4 +- go.sum | 4 +- pkg/auth/token.go | 56 ++++++++++++++-------------- pkg/auth/token_test.go | 84 ++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 129 insertions(+), 56 deletions(-) create mode 100644 pkg/auth/token_test.go diff --git a/api/otp.go b/api/otp.go index cd1fc7ac..499f2ebf 100644 --- a/api/otp.go +++ b/api/otp.go @@ -15,8 +15,8 @@ import ( "strings" "time" - jwt "github.com/dgrijalva/jwt-go" "github.com/gin-gonic/gin" + jwt "github.com/golang-jwt/jwt/v4" "github.com/sdslabs/beastv4/core" "github.com/sdslabs/beastv4/core/config" "github.com/sdslabs/beastv4/core/database" @@ -428,14 +428,16 @@ func verifyOTPForForgetHandler(c *gin.Context) { return } - t := time.Now().Unix() + now := time.Now() token := jwt.NewWithClaims(jwt.SigningMethodHS256, auth.CustomClaims{ - User: userEntry.Username, - Role: userEntry.Role, - ExpiresAt: t + 300, - IssuedAt: t, - Issuer: auth.ISSUER, + User: userEntry.Username, + Role: userEntry.Role, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(now.Add(5 * time.Minute)), + IssuedAt: jwt.NewNumericDate(now), + Issuer: auth.ISSUER, + }, }) tempToken, err := token.SignedString([]byte(auth.JWTSECRET)) diff --git a/core/utils/general.go b/core/utils/general.go index 80789567..47e2cf9d 100644 --- a/core/utils/general.go +++ b/core/utils/general.go @@ -1,8 +1,6 @@ package utils import ( - b64 "encoding/base64" - "encoding/json" "fmt" "strings" @@ -15,24 +13,13 @@ func GetUser(authHeader string) (string, error) { } values := strings.Split(authHeader, " ") - if len(values) < 2 { + if len(values) != 2 || values[0] != "Bearer" { return "", fmt.Errorf("Not a valid authorization header") } - jwtToken := values[1] - userInfoEncr := strings.Split(jwtToken, ".") - if len(userInfoEncr) != 3 { - return "", fmt.Errorf("Not a valid JWT token in authorization header: %s", jwtToken) - } - - sDec, err := b64.StdEncoding.WithPadding(b64.NoPadding).DecodeString(userInfoEncr[1]) + claims, err := auth.AuthorizeClaims(values[1], auth.MANAGER|auth.ADMIN|auth.USER) if err != nil { - return "", fmt.Errorf("Error in decrypting JWT token: %s", err) + return "", fmt.Errorf("invalid authorization token: %w", err) } - - in := []byte(sDec) - var raw auth.CustomClaims - json.Unmarshal(in, &raw) - - return raw.User, nil + return claims.User, nil } diff --git a/go.mod b/go.mod index 21e0d0cd..b09459d4 100644 --- a/go.mod +++ b/go.mod @@ -6,12 +6,12 @@ require ( github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/bubbletea v1.3.4 - github.com/dgrijalva/jwt-go v3.2.0+incompatible github.com/docker/docker v20.10.22+incompatible github.com/docker/go-connections v0.4.0 github.com/gin-contrib/cors v1.3.1 github.com/gin-contrib/static v0.0.0-20200916080430-d45d9a37d28e github.com/gin-gonic/gin v1.7.0 + github.com/golang-jwt/jwt/v4 v4.5.2 github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.5.5 github.com/jinzhu/gorm v1.9.1 @@ -28,6 +28,7 @@ require ( golang.org/x/net v0.31.0 golang.org/x/term v0.26.0 gopkg.in/src-d/go-git.v4 v4.7.0 + gopkg.in/yaml.v2 v2.4.0 gorm.io/driver/postgres v1.5.11 gorm.io/gorm v1.25.10 ) @@ -114,7 +115,6 @@ require ( gopkg.in/src-d/go-billy.v4 v4.3.0 // indirect gopkg.in/src-d/go-git-fixtures.v3 v3.3.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect gotest.tools/v3 v3.4.0 // indirect ) diff --git a/go.sum b/go.sum index cf05334d..d29f8a3c 100644 --- a/go.sum +++ b/go.sum @@ -51,8 +51,6 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/denisenkom/go-mssqldb v0.0.0-20180901172138-1eb28afdf9b6 h1:BZGp1dbKFjqlGmxEpwkDpCWNxVwEYnUPoncIzLiHlPo= github.com/denisenkom/go-mssqldb v0.0.0-20180901172138-1eb28afdf9b6/go.mod h1:xN/JuLBIz4bjkxNmByTiV1IbhfnYb6oo99phBn4Eqhc= -github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= -github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/docker/distribution v2.8.1+incompatible h1:Q50tZOPR6T/hjNsyc9g8/syEs6bk8XXApsHjKukMl68= @@ -105,6 +103,8 @@ github.com/go-sql-driver/mysql v1.4.0 h1:7LxgVwFb2hIQtMm87NdgAVfXjnt4OePseqT1tKx github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.3 h1:gyjaxf+svBWX08ZjK86iN9geUJF0H6gp2IRKX6Nf6/I= diff --git a/pkg/auth/token.go b/pkg/auth/token.go index 6f087249..ff19cdef 100644 --- a/pkg/auth/token.go +++ b/pkg/auth/token.go @@ -4,15 +4,13 @@ import ( "fmt" "time" - jwt "github.com/dgrijalva/jwt-go" + jwt "github.com/golang-jwt/jwt/v4" ) type CustomClaims struct { - User string `json:"usr"` - Role string `json:"eml"` - ExpiresAt int64 `json:"exp"` - IssuedAt int64 `json:"iat"` - Issuer string `json:"iss"` + User string `json:"usr"` + Role string `json:"role"` + jwt.RegisteredClaims } const ( @@ -27,49 +25,51 @@ var ( UserRoles []string ) -func (c CustomClaims) Valid() error { - if c.ExpiresAt < time.Now().Unix() { - return fmt.Errorf("Token Expired") - } - - return nil -} - -func Authorize(jwtTokenString string, roleAccess int) error { +func AuthorizeClaims(jwtTokenString string, roleAccess int) (*CustomClaims, error) { token, err := jwt.ParseWithClaims(jwtTokenString, &CustomClaims{}, func(token *jwt.Token) (interface{}, error) { - if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { - return nil, fmt.Errorf("Token invalid") + if token.Method != jwt.SigningMethodHS256 { + return nil, fmt.Errorf("token signing method is invalid") } return []byte(JWTSECRET), nil - }) + }, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()})) if err != nil { - return err + return nil, err } claims, ok := token.Claims.(*CustomClaims) if !ok || !token.Valid { - return fmt.Errorf("Token invalid") + return nil, fmt.Errorf("token is invalid") + } + if claims.Issuer != ISSUER { + return nil, fmt.Errorf("token issuer is invalid") } if !((roleAccess&MANAGER) != 0 && contains(ManagerRoles, claims.Role) || (roleAccess&ADMIN) != 0 && contains(AdminRoles, claims.Role) || (roleAccess&USER) != 0 && contains(UserRoles, claims.Role)) { - return fmt.Errorf("Role Access Error") + return nil, fmt.Errorf("role access error") } - return token.Claims.Valid() + return claims, nil +} + +func Authorize(jwtTokenString string, roleAccess int) error { + _, err := AuthorizeClaims(jwtTokenString, roleAccess) + return err } func GenerateJWT(authEntry AuthModel) (string, error) { - t := time.Now().Unix() + now := time.Now() token := jwt.NewWithClaims(jwt.SigningMethodHS256, CustomClaims{ - User: authEntry.Username, - Role: authEntry.Role, - ExpiresAt: t + TIME_PERIOD, - IssuedAt: t, - Issuer: ISSUER, + User: authEntry.Username, + Role: authEntry.Role, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(now.Add(time.Duration(TIME_PERIOD) * time.Second)), + IssuedAt: jwt.NewNumericDate(now), + Issuer: ISSUER, + }, }) return token.SignedString([]byte(JWTSECRET)) diff --git a/pkg/auth/token_test.go b/pkg/auth/token_test.go new file mode 100644 index 00000000..a6739207 --- /dev/null +++ b/pkg/auth/token_test.go @@ -0,0 +1,84 @@ +package auth + +import ( + "testing" + "time" + + jwt "github.com/golang-jwt/jwt/v4" +) + +func initializeTokenTest() { + Init(1, 32, 60, "test-issuer", "test-secret", []string{"author"}, []string{"admin"}, []string{"user"}) +} + +func TestAuthorizeClaimsValidatesToken(t *testing.T) { + initializeTokenTest() + token, err := GenerateJWT(AuthModel{Username: "alice", Role: "admin"}) + if err != nil { + t.Fatal(err) + } + claims, err := AuthorizeClaims(token, ADMIN) + if err != nil { + t.Fatal(err) + } + if claims.User != "alice" { + t.Fatalf("unexpected user %q", claims.User) + } +} + +func TestAuthorizeClaimsRejectsOtherHMACMethods(t *testing.T) { + initializeTokenTest() + token := jwt.NewWithClaims(jwt.SigningMethodHS384, CustomClaims{ + User: "alice", + Role: "admin", + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Minute)), + Issuer: ISSUER, + }, + }) + signed, err := token.SignedString([]byte(JWTSECRET)) + if err != nil { + t.Fatal(err) + } + if _, err := AuthorizeClaims(signed, ADMIN); err == nil { + t.Fatal("expected signing method rejection") + } +} + +func TestAuthorizeClaimsRejectsWrongIssuer(t *testing.T) { + initializeTokenTest() + token := jwt.NewWithClaims(jwt.SigningMethodHS256, CustomClaims{ + User: "alice", + Role: "admin", + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Minute)), + Issuer: "attacker", + }, + }) + signed, err := token.SignedString([]byte(JWTSECRET)) + if err != nil { + t.Fatal(err) + } + if _, err := AuthorizeClaims(signed, ADMIN); err == nil { + t.Fatal("expected issuer rejection") + } +} + +func TestAuthorizeClaimsRejectsExpiredToken(t *testing.T) { + initializeTokenTest() + token := jwt.NewWithClaims(jwt.SigningMethodHS256, CustomClaims{ + User: "alice", + Role: "admin", + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(-time.Minute)), + Issuer: ISSUER, + }, + }) + signed, err := token.SignedString([]byte(JWTSECRET)) + if err != nil { + t.Fatal(err) + } + if _, err := AuthorizeClaims(signed, ADMIN); err == nil { + t.Fatal("expected expiration rejection") + } +} From 8b4e3a0cf4468c38fa5898a316f71d2b9063b99b Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Wed, 15 Jul 2026 23:38:23 +0530 Subject: [PATCH 029/149] fix(auth): handle cryptographic randomness failures Signed-off-by: vibhatsu --- api/auth.go | 15 ++++++++++++--- cmd/beast/create_admin_author.go | 8 ++++++-- cmd/beast/init.go | 4 +++- core/database/database.go | 14 +++++++++----- core/manager/utils.go | 6 +++++- core/utils/command_utils.go | 14 +++++++++----- pkg/auth/auth.go | 15 +++++++++------ pkg/auth/auth_test.go | 21 +++++++++++++++++++++ 8 files changed, 74 insertions(+), 23 deletions(-) create mode 100644 pkg/auth/auth_test.go diff --git a/api/auth.go b/api/auth.go index 82ceb4e4..e5f7d37a 100644 --- a/api/auth.go +++ b/api/auth.go @@ -227,9 +227,14 @@ func register(c *gin.Context) { return } + authModel, err := auth.CreateModel(username, password, core.USER_ROLES["contestant"]) + if err != nil { + c.JSON(http.StatusInternalServerError, HTTPErrorResp{Error: "Failed to secure user credentials"}) + return + } userEntry := database.User{ Name: name, - AuthModel: auth.CreateModel(username, password, core.USER_ROLES["contestant"]), + AuthModel: authModel, Email: email, } @@ -264,7 +269,7 @@ func register(c *gin.Context) { } } } - err := database.CreateUserEntry(&userEntry) + err = database.CreateUserEntry(&userEntry) if err != nil { c.JSON(http.StatusNotAcceptable, HTTPErrorResp{ @@ -309,7 +314,11 @@ func resetPasswordHandler(c *gin.Context) { }) } - authModel := auth.CreateModel(username, newPass, user.Role) + authModel, err := auth.CreateModel(username, newPass, user.Role) + if err != nil { + c.JSON(http.StatusInternalServerError, HTTPPlainResp{Message: "Failed to secure user credentials"}) + return + } err = database.UpdateUser(&user, map[string]interface{}{"Password": authModel.Password, "Salt": authModel.Salt}) if err != nil { diff --git a/cmd/beast/create_admin_author.go b/cmd/beast/create_admin_author.go index d7192b65..f9b616c1 100644 --- a/cmd/beast/create_admin_author.go +++ b/cmd/beast/create_admin_author.go @@ -44,7 +44,9 @@ var createAuthorCmd = &cobra.Command{ Run: func(cmd *cobra.Command, args []string) { createAuthorAdminPrereq() - utils.CreateAdminOrAuthor(Name, Username, Email, Password, "author") + if err := utils.CreateAdminOrAuthor(Name, Username, Email, Password, "author"); err != nil { + fmt.Printf("Unable to create author: %v\n", err) + } }, } @@ -75,6 +77,8 @@ var createAdminCmd = &cobra.Command{ Run: func(cmd *cobra.Command, args []string) { createAuthorAdminPrereq() - utils.CreateAdminOrAuthor(Name, Username, Email, Password, "admin") + if err := utils.CreateAdminOrAuthor(Name, Username, Email, Password, "admin"); err != nil { + fmt.Printf("Unable to create admin: %v\n", err) + } }, } diff --git a/cmd/beast/init.go b/cmd/beast/init.go index f4b0fc0c..9cc2eb6a 100644 --- a/cmd/beast/init.go +++ b/cmd/beast/init.go @@ -286,7 +286,9 @@ func initAdmin() error { database.Init() createAuthorAdminPrereq() - coreUtils.CreateAdminOrAuthor(name, username, email, password, "admin") + if err := coreUtils.CreateAdminOrAuthor(name, username, email, password, "admin"); err != nil { + return err + } } return nil diff --git a/core/database/database.go b/core/database/database.go index b632f110..c15d402b 100644 --- a/core/database/database.go +++ b/core/database/database.go @@ -105,15 +105,19 @@ func Init() { if len(users) == 0 { log.Info("Creating dummy user entry") - salt := make([]byte, 16) - rand.Read(salt) randPass := make([]byte, 32) - rand.Read(randPass) + if _, err := rand.Read(randPass); err != nil { + log.Fatalf("failed to generate dummy user password: %s", err) + } + authModel, err := auth.CreateModel(core.DEFAULT_USER_NAME, string(randPass), core.USER_ROLES["author"]) + if err != nil { + log.Fatalf("failed to hash dummy user password: %s", err) + } - err := CreateUserEntry(&User{ + err = CreateUserEntry(&User{ Name: core.DEFAULT_USER_NAME, Email: core.DEFAULT_USER_EMAIL, - AuthModel: auth.CreateModel(core.DEFAULT_USER_NAME, string(randPass), core.USER_ROLES["author"]), + AuthModel: authModel, }) if err != nil { diff --git a/core/manager/utils.go b/core/manager/utils.go index ccf280ca..25c23b0d 100644 --- a/core/manager/utils.go +++ b/core/manager/utils.go @@ -451,9 +451,13 @@ func UpdateOrCreateChallengeDbEntry(challEntry *database.Challenge, config cfg.B // return fmt.Errorf("User with the given email does not exist : %v. You can pass q flag with password to autogenerate authors in this case.", config.Author.Email) // } log.Infof("User with the given email does not exist : %v, creating this user", config.Author.Email) + authModel, err := auth.CreateModel(config.Author.Email, defaultauthorpassword, core.USER_ROLES["author"]) + if err != nil { + return fmt.Errorf("create author credentials: %w", err) + } newUser := database.User{ Name: config.Author.Name, - AuthModel: auth.CreateModel(config.Author.Email, defaultauthorpassword, core.USER_ROLES["author"]), + AuthModel: authModel, Email: config.Author.Email, } err = database.CreateUserEntry(&newUser) diff --git a/core/utils/command_utils.go b/core/utils/command_utils.go index e6479536..7b1b5037 100644 --- a/core/utils/command_utils.go +++ b/core/utils/command_utils.go @@ -6,19 +6,23 @@ import ( "github.com/sdslabs/beastv4/core" "github.com/sdslabs/beastv4/core/database" "github.com/sdslabs/beastv4/pkg/auth" - log "github.com/sirupsen/logrus" ) -func CreateAdminOrAuthor(name string, username string, email string, password string, role string) { +func CreateAdminOrAuthor(name string, username string, email string, password string, role string) error { + authModel, err := auth.CreateModel(username, password, core.USER_ROLES[role]) + if err != nil { + return err + } userEntry := database.User{ Name: name, - AuthModel: auth.CreateModel(username, password, core.USER_ROLES[role]), + AuthModel: authModel, Email: email, } - err := database.CreateUserEntry(&userEntry) + err = database.CreateUserEntry(&userEntry) if err != nil { - log.Errorf("Error while creating author entry : %v", err) + return fmt.Errorf("create author entry: %w", err) } + return nil } func DeleteChallengeEntryWithPorts(challname string) error { diff --git a/pkg/auth/auth.go b/pkg/auth/auth.go index 313b4b80..ef58fbad 100644 --- a/pkg/auth/auth.go +++ b/pkg/auth/auth.go @@ -1,10 +1,12 @@ package auth import ( - "bytes" "crypto/rand" "crypto/sha256" + "crypto/subtle" "errors" + "fmt" + "io" _ "github.com/jinzhu/gorm/dialects/sqlite" "golang.org/x/crypto/pbkdf2" @@ -25,10 +27,11 @@ type AuthModel struct { Salt []byte } -func CreateModel(username, password, role string) AuthModel { - +func CreateModel(username, password, role string) (AuthModel, error) { salt := make([]byte, 16) - rand.Read(salt) + if _, err := io.ReadFull(rand.Reader, salt); err != nil { + return AuthModel{}, fmt.Errorf("generate password salt: %w", err) + } auth1 := AuthModel{ Username: username, @@ -36,12 +39,12 @@ func CreateModel(username, password, role string) AuthModel { Salt: salt, Role: role, } - return auth1 + return auth1, nil } func Authenticate(username, password string, authEntry AuthModel) (string, error) { hashedPassword := pbkdf2.Key([]byte(password), authEntry.Salt, ITERATIONS, HASH_LENGTH, sha256.New) - if !bytes.Equal(hashedPassword, authEntry.Password) { + if subtle.ConstantTimeCompare(hashedPassword, authEntry.Password) != 1 { return "", errors.New("The username or password is invalid") } diff --git a/pkg/auth/auth_test.go b/pkg/auth/auth_test.go new file mode 100644 index 00000000..bdcda406 --- /dev/null +++ b/pkg/auth/auth_test.go @@ -0,0 +1,21 @@ +package auth + +import ( + "bytes" + "testing" +) + +func TestCreateModelUsesUniqueSalt(t *testing.T) { + Init(100, 32, 60, "issuer", "secret", nil, nil, nil) + first, err := CreateModel("alice", "password", "user") + if err != nil { + t.Fatal(err) + } + second, err := CreateModel("alice", "password", "user") + if err != nil { + t.Fatal(err) + } + if bytes.Equal(first.Salt, second.Salt) || bytes.Equal(first.Password, second.Password) { + t.Fatal("password models reused salt-derived data") + } +} From 1fb4133e3245ef508f0378a52f40cb64536cbcfb Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Thu, 16 Jul 2026 17:55:48 +0530 Subject: [PATCH 030/149] fix(auth): remove authorization bypass mode Signed-off-by: vibhatsu --- api/auth.go | 123 +++++++--------------------- api/auth_middleware_test.go | 44 +++++++++++ api/otp.go | 154 ++++++++++++++++-------------------- cmd/beast/commands.go | 8 -- core/config/config.go | 1 - 5 files changed, 141 insertions(+), 189 deletions(-) create mode 100644 api/auth_middleware_test.go diff --git a/api/auth.go b/api/auth.go index e5f7d37a..eb489b71 100644 --- a/api/auth.go +++ b/api/auth.go @@ -23,16 +23,9 @@ import ( // @Produce json // @Failure 401 {object} api.HTTPPlainResp // @Security ApiKeyAuth -func authorize(c *gin.Context) { - if config.SkipAuthorization { - return - } - - authHeader := c.GetHeader("Authorization") - - values := strings.Split(authHeader, " ") - - if len(values) < 2 || values[0] != "Bearer" { +func authorizeRoles(c *gin.Context, roles int) { + values := strings.Fields(c.GetHeader("Authorization")) + if len(values) != 2 || values[0] != "Bearer" { c.JSON(http.StatusUnauthorized, HTTPPlainResp{ Message: "No Token Provided", }) @@ -40,8 +33,7 @@ func authorize(c *gin.Context) { return } - err := auth.Authorize(values[1], core.MANAGER|core.ADMIN|core.USER) - + claims, err := auth.AuthorizeClaims(values[1], roles) if err != nil { c.JSON(http.StatusUnauthorized, HTTPPlainResp{ Message: err.Error(), @@ -49,10 +41,14 @@ func authorize(c *gin.Context) { c.Abort() return } - + c.Set("authClaims", claims) c.Next() } +func authorize(c *gin.Context) { + authorizeRoles(c, core.MANAGER|core.ADMIN|core.USER) +} + // Acts as a middleware to authorize manager roles // @Summary Handles authorization of manager roles // @Description Authorizes authors and admin by checking if JWT token exists and is valid @@ -62,33 +58,7 @@ func authorize(c *gin.Context) { // @Failure 401 {object} api.HTTPPlainResp // @Security ApiKeyAuth func managerAuthorize(c *gin.Context) { - if config.SkipAuthorization { - return - } - - authHeader := c.GetHeader("Authorization") - - values := strings.Split(authHeader, " ") - - if len(values) < 2 || values[0] != "Bearer" { - c.JSON(http.StatusUnauthorized, HTTPPlainResp{ - Message: "No Token Provided", - }) - c.Abort() - return - } - - err := auth.Authorize(values[1], core.MANAGER|core.ADMIN) - - if err != nil { - c.JSON(http.StatusUnauthorized, HTTPPlainResp{ - Message: err.Error(), - }) - c.Abort() - return - } - - c.Next() + authorizeRoles(c, core.MANAGER|core.ADMIN) } // Acts as a middleware to authorize admin roles @@ -100,33 +70,7 @@ func managerAuthorize(c *gin.Context) { // @Failure 401 {object} api.HTTPPlainResp // @Security ApiKeyAuth func adminAuthorize(c *gin.Context) { - if config.SkipAuthorization { - return - } - - authHeader := c.GetHeader("Authorization") - - values := strings.Split(authHeader, " ") - - if len(values) < 2 || values[0] != "Bearer" { - c.JSON(http.StatusUnauthorized, HTTPPlainResp{ - Message: "No Token Provided", - }) - c.Abort() - return - } - - err := auth.Authorize(values[1], core.ADMIN) - - if err != nil { - c.JSON(http.StatusUnauthorized, HTTPPlainResp{ - Message: err.Error(), - }) - c.Abort() - return - } - - c.Next() + authorizeRoles(c, core.ADMIN) } // Handles route related to receive JWT token @@ -238,36 +182,25 @@ func register(c *gin.Context) { Email: email, } - // skip otp verif if -n flag is enabled - if !config.SkipAuthorization { - smtpHost := config.Cfg.MailConfig.SMTPHost - smtpPort := config.Cfg.MailConfig.SMTPPort - - if smtpHost == "" || smtpPort == "" { - log.Printf("WARNING: %s", "SMTP not configured") + smtpHost := config.Cfg.MailConfig.SMTPHost + smtpPort := config.Cfg.MailConfig.SMTPPort + if smtpHost == "" || smtpPort == "" { + c.JSON(http.StatusServiceUnavailable, HTTPErrorResp{Error: "SMTP not configured"}) + return + } + otpEntry, err := database.QueryOTPEntry(email) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusUnauthorized, HTTPErrorResp{Error: "OTP not found, email not verified"}) } else { - otpEntry, err := database.QueryOTPEntry(email) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - c.JSON(http.StatusUnauthorized, HTTPErrorResp{ - Error: "OTP not found, email not verified", - }) - return - } else { - log.Println("Failed to query OTP:", err) - c.JSON(http.StatusInternalServerError, HTTPErrorResp{ - Error: "Failed to send OTP", - }) - return - } - } - if !otpEntry.Verified { - c.JSON(http.StatusNotAcceptable, HTTPErrorResp{ - Error: "Email not verified, cannot register user", - }) - return - } + log.Println("Failed to query OTP:", err) + c.JSON(http.StatusInternalServerError, HTTPErrorResp{Error: "Failed to verify OTP"}) } + return + } + if !otpEntry.Verified { + c.JSON(http.StatusNotAcceptable, HTTPErrorResp{Error: "Email not verified, cannot register user"}) + return } err = database.CreateUserEntry(&userEntry) diff --git a/api/auth_middleware_test.go b/api/auth_middleware_test.go new file mode 100644 index 00000000..0672fa8a --- /dev/null +++ b/api/auth_middleware_test.go @@ -0,0 +1,44 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/sdslabs/beastv4/core" + "github.com/sdslabs/beastv4/pkg/auth" +) + +func TestManagerAuthorizationRequiresVerifiedToken(t *testing.T) { + gin.SetMode(gin.TestMode) + auth.Init(1, 32, 60, "issuer", "secret", []string{"author"}, []string{"admin"}, []string{"contestant"}) + + router := gin.New() + router.GET("/protected", managerAuthorize, func(c *gin.Context) { + claims, exists := c.Get("authClaims") + if !exists || claims.(*auth.CustomClaims).User != "alice" { + c.Status(http.StatusInternalServerError) + return + } + c.Status(http.StatusNoContent) + }) + + unauthorized := httptest.NewRecorder() + router.ServeHTTP(unauthorized, httptest.NewRequest(http.MethodGet, "/protected", nil)) + if unauthorized.Code != http.StatusUnauthorized { + t.Fatalf("unauthorized status = %d", unauthorized.Code) + } + + token, err := auth.GenerateJWT(auth.AuthModel{Username: "alice", Role: core.USER_ROLES["author"]}) + if err != nil { + t.Fatal(err) + } + request := httptest.NewRequest(http.MethodGet, "/protected", nil) + request.Header.Set("Authorization", "Bearer "+token) + authorized := httptest.NewRecorder() + router.ServeHTTP(authorized, request) + if authorized.Code != http.StatusNoContent { + t.Fatalf("authorized status = %d, body = %s", authorized.Code, authorized.Body.String()) + } +} diff --git a/api/otp.go b/api/otp.go index 499f2ebf..cb0f80f7 100644 --- a/api/otp.go +++ b/api/otp.go @@ -155,9 +155,6 @@ func sendEmail(email, otp string) error { } func sendOTPHandler(c *gin.Context) { - if config.SkipAuthorization { - return - } email := c.PostForm("email") email = strings.TrimSpace(strings.ToLower(email)) @@ -232,63 +229,55 @@ func verifyOTPHandler(c *gin.Context) { otp := strings.TrimSpace(c.PostForm("otp")) email = strings.TrimSpace(strings.ToLower(email)) - if !config.SkipAuthorization { - smtpHost := config.Cfg.MailConfig.SMTPHost - smtpPort := config.Cfg.MailConfig.SMTPPort - - if smtpHost == "" || smtpPort == "" { - log.Printf("WARNING: %s", "SMTP not configured") - c.JSON(http.StatusInternalServerError, HTTPErrorResp{ - Error: "SMTP not configured", - }) - return - } + smtpHost := config.Cfg.MailConfig.SMTPHost + smtpPort := config.Cfg.MailConfig.SMTPPort - otpEntry, err := database.QueryOTPEntry(email) + if smtpHost == "" || smtpPort == "" { + log.Printf("WARNING: %s", "SMTP not configured") + c.JSON(http.StatusInternalServerError, HTTPErrorResp{ + Error: "SMTP not configured", + }) + return + } - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - c.JSON(http.StatusUnauthorized, HTTPErrorResp{ - Error: "OTP not found", - }) - } else { - log.Println("Failed to query OTP:", err) - c.JSON(http.StatusInternalServerError, HTTPErrorResp{ - Error: "Failed to send OTP", - }) - return - } - } + otpEntry, err := database.QueryOTPEntry(email) - if otpEntry.Verified { - c.JSON(http.StatusOK, HTTPPlainResp{ - Message: "Email already verified", + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusUnauthorized, HTTPErrorResp{ + Error: "OTP not found", }) return - } - - if err != nil { + } else { + log.Println("Failed to query OTP:", err) c.JSON(http.StatusInternalServerError, HTTPErrorResp{ - Error: "Failed to verify OTP", + Error: "Failed to send OTP", }) return } + } - if otpEntry.Code != otp { - c.JSON(http.StatusUnauthorized, HTTPErrorResp{ - Error: "Invalid OTP", - }) - return - } + if otpEntry.Verified { + c.JSON(http.StatusOK, HTTPPlainResp{ + Message: "Email already verified", + }) + return + } - if time.Now().After(otpEntry.Expiry) { - c.JSON(http.StatusUnauthorized, HTTPErrorResp{ - Error: "OTP expired", - }) - return - } + if otpEntry.Code != otp { + c.JSON(http.StatusUnauthorized, HTTPErrorResp{ + Error: "Invalid OTP", + }) + return + } + + if time.Now().After(otpEntry.Expiry) { + c.JSON(http.StatusUnauthorized, HTTPErrorResp{ + Error: "OTP expired", + }) + return } - err := database.VerifyOTPEntry(email) + err = database.VerifyOTPEntry(email) if err != nil { c.JSON(http.StatusInternalServerError, HTTPErrorResp{ @@ -303,9 +292,6 @@ func verifyOTPHandler(c *gin.Context) { } func sendOTPForForgetHandler(c *gin.Context) { - if config.SkipAuthorization { - return - } email := c.PostForm("email") email = strings.TrimSpace(strings.ToLower(email)) @@ -372,46 +358,44 @@ func verifyOTPForForgetHandler(c *gin.Context) { email := c.PostForm("email") otp := strings.TrimSpace(c.PostForm("otp")) email = strings.TrimSpace(strings.ToLower(email)) - if !config.SkipAuthorization { - smtpHost := config.Cfg.MailConfig.SMTPHost - smtpPort := config.Cfg.MailConfig.SMTPPort - - if smtpHost == "" || smtpPort == "" { - log.Printf("WARNING: %s", "SMTP not configured") - c.JSON(http.StatusInternalServerError, HTTPErrorResp{ - Error: "SMTP not configured", - }) - return - } + smtpHost := config.Cfg.MailConfig.SMTPHost + smtpPort := config.Cfg.MailConfig.SMTPPort - otpEntry, err := database.QueryOTPEntry(email) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - c.JSON(http.StatusUnauthorized, HTTPErrorResp{ - Error: "OTP not found", - }) - } else { - log.Println("Failed to query OTP:", err) - c.JSON(http.StatusInternalServerError, HTTPErrorResp{ - Error: "Failed to verify OTP", - }) - } - return - } + if smtpHost == "" || smtpPort == "" { + log.Printf("WARNING: %s", "SMTP not configured") + c.JSON(http.StatusInternalServerError, HTTPErrorResp{ + Error: "SMTP not configured", + }) + return + } - if otpEntry.Code != otp { + otpEntry, err := database.QueryOTPEntry(email) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { c.JSON(http.StatusUnauthorized, HTTPErrorResp{ - Error: "Invalid OTP", + Error: "OTP not found", }) - return - } - - if time.Now().After(otpEntry.Expiry) { - c.JSON(http.StatusUnauthorized, HTTPErrorResp{ - Error: "OTP expired", + } else { + log.Println("Failed to query OTP:", err) + c.JSON(http.StatusInternalServerError, HTTPErrorResp{ + Error: "Failed to verify OTP", }) - return } + return + } + + if otpEntry.Code != otp { + c.JSON(http.StatusUnauthorized, HTTPErrorResp{ + Error: "Invalid OTP", + }) + return + } + + if time.Now().After(otpEntry.Expiry) { + c.JSON(http.StatusUnauthorized, HTTPErrorResp{ + Error: "OTP expired", + }) + return } userEntry, err := database.QueryFirstUserEntry("email", email) if err != nil { diff --git a/cmd/beast/commands.go b/cmd/beast/commands.go index 3cfb62dc..d0f4b680 100644 --- a/cmd/beast/commands.go +++ b/cmd/beast/commands.go @@ -19,7 +19,6 @@ var ( Username string Email string Password string - SkipAuthorization bool AllChalls bool AutoDeploy bool PeriodicSync bool @@ -46,12 +45,6 @@ var rootCmd = &cobra.Command{ debug.Disable() } - if SkipAuthorization { - config.SkipAuthorization = true - } else { - config.SkipAuthorization = false - } - config.NoCache = NoCache }, Run: func(cmd *cobra.Command, args []string) { @@ -80,7 +73,6 @@ func init() { runCmd.PersistentFlags().BoolVarP(&AutoDeploy, "auto-deploy", "a", false, "Auto deploy all challenges from remote on server start.") runCmd.PersistentFlags().BoolVarP(&HealthProbe, "health-probe", "k", false, "Run health check service for beast deployed challenges") runCmd.PersistentFlags().BoolVarP(&PeriodicSync, "periodic-sync", "s", false, "Periodically sync remote with beast and auto update challenges.") - runCmd.PersistentFlags().BoolVarP(&SkipAuthorization, "noauth", "n", false, "Skip Authorization") runCmd.PersistentFlags().BoolVarP(&NoCache, "no-cache", "c", false, "Build image of challenge without using cache") getAuthCmd.PersistentFlags().StringVarP(&Username, "username", "u", "", "Username") diff --git a/core/config/config.go b/core/config/config.go index dda4c83a..5e49c692 100644 --- a/core/config/config.go +++ b/core/config/config.go @@ -523,7 +523,6 @@ func LoadBeastConfig(configPath string) (BeastConfig, error) { } var Cfg *BeastConfig -var SkipAuthorization bool var NoCache bool // InitConfig loads the config from the global config file and populate From d91ab387d23417768dda927a4447e73a3bcf5b21 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Thu, 16 Jul 2026 18:01:41 +0530 Subject: [PATCH 031/149] fix(server): require TLS for API traffic Signed-off-by: vibhatsu --- api/main.go | 6 +++++- api/main_test.go | 4 ++++ core/config/config.go | 26 ++++++++++++++++++++++++++ core/config/utils_test.go | 6 ++++++ 4 files changed, 41 insertions(+), 1 deletion(-) diff --git a/api/main.go b/api/main.go index f374945f..1e86aed2 100644 --- a/api/main.go +++ b/api/main.go @@ -2,6 +2,7 @@ package api import ( "context" + "crypto/tls" "errors" "fmt" "net/http" @@ -76,6 +77,9 @@ func newHTTPServer(address string, handler http.Handler) *http.Server { ReadTimeout: 30 * time.Second, IdleTimeout: 2 * time.Minute, MaxHeaderBytes: 64 << 10, + TLSConfig: &tls.Config{ + MinVersion: tls.VersionTLS12, + }, } } @@ -163,7 +167,7 @@ func RunBeastApiServer(ctx context.Context, port, defaultauthorpassword string, server := newHTTPServer(address, router) serverErr := make(chan error, 1) go func() { - serverErr <- server.ListenAndServe() + serverErr <- server.ListenAndServeTLS(config.Cfg.ServerConfig.TLSCertFile, config.Cfg.ServerConfig.TLSKeyFile) }() select { diff --git a/api/main_test.go b/api/main_test.go index 7fab3438..183ef498 100644 --- a/api/main_test.go +++ b/api/main_test.go @@ -1,6 +1,7 @@ package api import ( + "crypto/tls" "net/http" "testing" "time" @@ -28,4 +29,7 @@ func TestHTTPServerHasDefensiveTimeouts(t *testing.T) { if server.ReadHeaderTimeout > 30*time.Second { t.Fatalf("read header timeout is too permissive: %v", server.ReadHeaderTimeout) } + if server.TLSConfig == nil || server.TLSConfig.MinVersion < tls.VersionTLS12 { + t.Fatalf("missing minimum TLS version: %+v", server.TLSConfig) + } } diff --git a/core/config/config.go b/core/config/config.go index 5e49c692..f368ee0b 100644 --- a/core/config/config.go +++ b/core/config/config.go @@ -1,6 +1,7 @@ package config import ( + "crypto/tls" "errors" "fmt" "net/url" @@ -115,6 +116,7 @@ type BeastConfig struct { RemoteSyncPeriod time.Duration `toml:"-"` Rsp string `toml:"remote_sync_period"` InstanceConfig InstanceConfig `toml:"instance_config"` + ServerConfig ServerConfig `toml:"server"` CPUShares int64 `toml:"default_cpu_shares"` Memory int64 `toml:"default_memory_limit"` @@ -130,6 +132,27 @@ type InstanceConfig struct { MaxInstancesPerUser int `toml:"max_instances_per_user"` } +type ServerConfig struct { + TLSCertFile string `toml:"tls_cert_file"` + TLSKeyFile string `toml:"tls_key_file"` +} + +func (config *ServerConfig) Validate() error { + if config.TLSCertFile == "" || config.TLSKeyFile == "" { + return errors.New("server tls_cert_file and tls_key_file are required") + } + if err := utils.ValidateFileExists(config.TLSCertFile); err != nil { + return fmt.Errorf("invalid TLS certificate file: %w", err) + } + if err := utils.ValidateSecretFile(config.TLSKeyFile); err != nil { + return fmt.Errorf("invalid TLS private key file: %w", err) + } + if _, err := tls.LoadX509KeyPair(config.TLSCertFile, config.TLSKeyFile); err != nil { + return fmt.Errorf("load TLS certificate and key: %w", err) + } + return nil +} + func (config *InstanceConfig) Validate() { if config.DefaultExpiration <= 0 { config.DefaultExpiration = core.DEFAULT_MINIMUM_EXTEND_TIME @@ -277,6 +300,9 @@ func (config *BeastConfig) ValidateConfig() error { } config.InstanceConfig.Validate() + if err := config.ServerConfig.Validate(); err != nil { + return err + } return nil } diff --git a/core/config/utils_test.go b/core/config/utils_test.go index d5697867..5f9addea 100644 --- a/core/config/utils_test.go +++ b/core/config/utils_test.go @@ -147,3 +147,9 @@ func TestResourcesUseGlobalDefaults(t *testing.T) { t.Fatalf("unexpected defaults: %+v", resources) } } + +func TestServerConfigRequiresTLSFiles(t *testing.T) { + if err := (&ServerConfig{}).Validate(); err == nil || !strings.Contains(err.Error(), "required") { + t.Fatalf("expected required TLS files error, got %v", err) + } +} From 69b4928cbff541fa179c93c7fc2101da288720e9 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Thu, 16 Jul 2026 18:04:33 +0530 Subject: [PATCH 032/149] feat(runtime): execute bounded container commands Signed-off-by: vibhatsu --- pkg/cr/exec.go | 115 ++++++++++++++++++++++++++++++++++++++++++++ pkg/cr/exec_test.go | 21 ++++++++ 2 files changed, 136 insertions(+) create mode 100644 pkg/cr/exec.go create mode 100644 pkg/cr/exec_test.go diff --git a/pkg/cr/exec.go b/pkg/cr/exec.go new file mode 100644 index 00000000..8c16fbf0 --- /dev/null +++ b/pkg/cr/exec.go @@ -0,0 +1,115 @@ +package cr + +import ( + "bytes" + "context" + "fmt" + "sync" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/pkg/stdcopy" +) + +const DefaultExecOutputLimit = 1 << 20 + +type ExecResult struct { + Stdout string + Stderr string + ExitCode int + Truncated bool +} + +type cappedBuffer struct { + buffer bytes.Buffer + remaining int + truncated bool + mutex sync.Mutex +} + +func (buffer *cappedBuffer) Write(data []byte) (int, error) { + buffer.mutex.Lock() + defer buffer.mutex.Unlock() + written := len(data) + if len(data) > buffer.remaining { + data = data[:buffer.remaining] + buffer.truncated = true + } + if len(data) > 0 { + _, _ = buffer.buffer.Write(data) + buffer.remaining -= len(data) + } + return written, nil +} + +func (buffer *cappedBuffer) String() string { + buffer.mutex.Lock() + defer buffer.mutex.Unlock() + return buffer.buffer.String() +} + +func (buffer *cappedBuffer) Truncated() bool { + buffer.mutex.Lock() + defer buffer.mutex.Unlock() + return buffer.truncated +} + +func ExecContainer(ctx context.Context, containerID string, command []string, outputLimit int) (ExecResult, error) { + if containerID == "" { + return ExecResult{}, fmt.Errorf("container ID is empty") + } + if len(command) == 0 { + return ExecResult{}, fmt.Errorf("command is empty") + } + if outputLimit <= 0 { + outputLimit = DefaultExecOutputLimit + } + + client, err := newDockerClient() + if err != nil { + return ExecResult{}, fmt.Errorf("create Docker client: %w", err) + } + exec, err := client.ContainerExecCreate(ctx, containerID, types.ExecConfig{ + User: "0", + AttachStdout: true, + AttachStderr: true, + Cmd: command, + }) + if err != nil { + return ExecResult{}, fmt.Errorf("create container exec: %w", err) + } + response, err := client.ContainerExecAttach(ctx, exec.ID, types.ExecStartCheck{}) + if err != nil { + return ExecResult{}, fmt.Errorf("attach container exec: %w", err) + } + defer response.Close() + + stopCancellation := make(chan struct{}) + defer close(stopCancellation) + go func() { + select { + case <-ctx.Done(): + response.Close() + case <-stopCancellation: + } + }() + + stdout := &cappedBuffer{remaining: (outputLimit + 1) / 2} + stderr := &cappedBuffer{remaining: outputLimit / 2} + if _, err := stdcopy.StdCopy(stdout, stderr, response.Reader); err != nil && ctx.Err() == nil { + return ExecResult{}, fmt.Errorf("read container exec output: %w", err) + } + if err := ctx.Err(); err != nil { + return ExecResult{}, err + } + inspection, err := client.ContainerExecInspect(ctx, exec.ID) + if err != nil { + return ExecResult{}, fmt.Errorf("inspect container exec: %w", err) + } + + return ExecResult{ + Stdout: stdout.String(), + Stderr: stderr.String(), + ExitCode: inspection.ExitCode, + Truncated: stdout.Truncated() || stderr.Truncated(), + }, nil +} diff --git a/pkg/cr/exec_test.go b/pkg/cr/exec_test.go new file mode 100644 index 00000000..fe58f2eb --- /dev/null +++ b/pkg/cr/exec_test.go @@ -0,0 +1,21 @@ +package cr + +import "testing" + +func TestCappedBufferDiscardsExcessWithoutBlockingWriter(t *testing.T) { + buffer := &cappedBuffer{remaining: 4} + data := []byte("abcdefgh") + written, err := buffer.Write(data) + if err != nil { + t.Fatal(err) + } + if written != len(data) { + t.Fatalf("reported written bytes = %d, want %d", written, len(data)) + } + if got := buffer.String(); got != "abcd" { + t.Fatalf("buffer = %q", got) + } + if !buffer.Truncated() { + t.Fatal("expected truncation marker") + } +} From 741e1f479ba6ff5fec8015f7b06cd91a68352b2a Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Thu, 16 Jul 2026 18:07:19 +0530 Subject: [PATCH 033/149] feat(remote): execute quoted container commands Signed-off-by: vibhatsu --- pkg/remoteManager/exec.go | 104 +++++++++++++++++++++++++++++++++ pkg/remoteManager/exec_test.go | 22 +++++++ 2 files changed, 126 insertions(+) create mode 100644 pkg/remoteManager/exec.go create mode 100644 pkg/remoteManager/exec_test.go diff --git a/pkg/remoteManager/exec.go b/pkg/remoteManager/exec.go new file mode 100644 index 00000000..2fc33ad0 --- /dev/null +++ b/pkg/remoteManager/exec.go @@ -0,0 +1,104 @@ +package remoteManager + +import ( + "bytes" + "context" + "errors" + "fmt" + "strings" + "sync" + + "github.com/sdslabs/beastv4/core/config" + "github.com/sdslabs/beastv4/pkg/cr" + "golang.org/x/crypto/ssh" +) + +type cappedCommandBuffer struct { + buffer bytes.Buffer + remaining int + truncated bool + mutex sync.Mutex +} + +func (buffer *cappedCommandBuffer) Write(data []byte) (int, error) { + buffer.mutex.Lock() + defer buffer.mutex.Unlock() + written := len(data) + if len(data) > buffer.remaining { + data = data[:buffer.remaining] + buffer.truncated = true + } + if len(data) > 0 { + _, _ = buffer.buffer.Write(data) + buffer.remaining -= len(data) + } + return written, nil +} + +func shellQuote(value string) string { + if value == "" { + return "''" + } + return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'" +} + +func shellJoin(arguments []string) string { + quoted := make([]string, len(arguments)) + for index, argument := range arguments { + quoted[index] = shellQuote(argument) + } + return strings.Join(quoted, " ") +} + +func ExecContainerRemote(ctx context.Context, server config.AvailableServer, containerID string, command []string, outputLimit int) (cr.ExecResult, error) { + if containerID == "" || len(command) == 0 { + return cr.ExecResult{}, fmt.Errorf("container ID and command are required") + } + if outputLimit <= 0 { + outputLimit = cr.DefaultExecOutputLimit + } + client, err := CreateSSHClient(server) + if err != nil { + return cr.ExecResult{}, err + } + defer client.Close() + session, err := client.NewSession() + if err != nil { + return cr.ExecResult{}, fmt.Errorf("create SSH session: %w", err) + } + defer session.Close() + + stdout := &cappedCommandBuffer{remaining: (outputLimit + 1) / 2} + stderr := &cappedCommandBuffer{remaining: outputLimit / 2} + session.Stdout = stdout + session.Stderr = stderr + arguments := append([]string{"docker", "exec", "--user", "0", containerID}, command...) + if err := session.Start(shellJoin(arguments)); err != nil { + return cr.ExecResult{}, fmt.Errorf("start remote container exec: %w", err) + } + waitResult := make(chan error, 1) + go func() { waitResult <- session.Wait() }() + + select { + case <-ctx.Done(): + _ = session.Close() + _ = client.Close() + <-waitResult + return cr.ExecResult{}, ctx.Err() + case err := <-waitResult: + exitCode := 0 + if err != nil { + var exitError *ssh.ExitError + if !errors.As(err, &exitError) { + return cr.ExecResult{}, fmt.Errorf("execute remote container command: %w", err) + } + exitCode = exitError.ExitStatus() + } + return cr.ExecResult{ + Stdout: stdout.buffer.String(), + Stderr: stderr.buffer.String(), + ExitCode: exitCode, + Truncated: stdout.truncated || stderr.truncated, + }, nil + } +} diff --git a/pkg/remoteManager/exec_test.go b/pkg/remoteManager/exec_test.go new file mode 100644 index 00000000..38654850 --- /dev/null +++ b/pkg/remoteManager/exec_test.go @@ -0,0 +1,22 @@ +package remoteManager + +import "testing" + +func TestShellJoinQuotesEveryArgument(t *testing.T) { + got := shellJoin([]string{"docker", "name; touch /tmp/pwned", "it's", ""}) + want := `'docker' 'name; touch /tmp/pwned' 'it'"'"'s' ''` + if got != want { + t.Fatalf("shellJoin() = %q, want %q", got, want) + } +} + +func TestCappedCommandBufferDiscardsExcess(t *testing.T) { + buffer := &cappedCommandBuffer{remaining: 3} + written, err := buffer.Write([]byte("abcdef")) + if err != nil { + t.Fatal(err) + } + if written != 6 || buffer.buffer.String() != "abc" || !buffer.truncated { + t.Fatalf("unexpected buffer state: written=%d value=%q truncated=%t", written, buffer.buffer.String(), buffer.truncated) + } +} From d62fefba6b906a7d999806b2c7908d3785b814dd Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Thu, 16 Jul 2026 18:21:57 +0530 Subject: [PATCH 034/149] feat(auth): query challenge maintainers Signed-off-by: vibhatsu --- core/database/authorization_test.go | 29 +++++++++++++++++++++++++++++ core/database/user.go | 10 ++++++++++ 2 files changed, 39 insertions(+) create mode 100644 core/database/authorization_test.go diff --git a/core/database/authorization_test.go b/core/database/authorization_test.go new file mode 100644 index 00000000..70e2fc04 --- /dev/null +++ b/core/database/authorization_test.go @@ -0,0 +1,29 @@ +package database + +import "testing" + +func TestIsChallengeMaintainer(t *testing.T) { + cleanup := setupSubmissionTestDB(t) + defer cleanup() + + user := createSubmissionTestUser(t, "author") + challenge := createSubmissionTestChallenge(t, "challenge", 0, false) + if err := Db.Create(&UserChallenges{UserID: user.ID, ChallengeID: challenge.ID}).Error; err != nil { + t.Fatal(err) + } + + allowed, err := IsChallengeMaintainer(user.ID, challenge.ID) + if err != nil { + t.Fatal(err) + } + if !allowed { + t.Fatal("expected related user to be a maintainer") + } + allowed, err = IsChallengeMaintainer(user.ID+1, challenge.ID) + if err != nil { + t.Fatal(err) + } + if allowed { + t.Fatal("unrelated user was treated as maintainer") + } +} diff --git a/core/database/user.go b/core/database/user.go index 5fa01338..02b9fec0 100644 --- a/core/database/user.go +++ b/core/database/user.go @@ -153,6 +153,16 @@ func GetRelatedChallenges(user *User) ([]Challenge, error) { return challenges, nil } +func IsChallengeMaintainer(userID, challengeID uint) (bool, error) { + var count int64 + DBMux.Lock() + defer DBMux.Unlock() + err := Db.Table("user_challenges"). + Where("user_id = ? AND challenge_id = ?", userID, challengeID). + Count(&count).Error + return count > 0, err +} + // UserSolvedChallenge represents a challenge solved by a user with the actual solve timestamp type UserSolvedChallenge struct { ChallengeID uint From 1d4737b14dc2fa938cec9f246a9b1306902821a4 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Thu, 16 Jul 2026 18:29:03 +0530 Subject: [PATCH 035/149] feat(api): add authorized container exec endpoint Signed-off-by: vibhatsu --- api/exec.go | 172 +++++++++++++++++++++++++++++++ api/exec_test.go | 47 +++++++++ api/main.go | 2 +- api/router.go | 1 + cmd/beast/create_admin_author.go | 2 +- 5 files changed, 222 insertions(+), 2 deletions(-) create mode 100644 api/exec.go create mode 100644 api/exec_test.go diff --git a/api/exec.go b/api/exec.go new file mode 100644 index 00000000..1bb6a0ff --- /dev/null +++ b/api/exec.go @@ -0,0 +1,172 @@ +package api + +import ( + "context" + "errors" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/sdslabs/beastv4/core" + "github.com/sdslabs/beastv4/core/cache" + "github.com/sdslabs/beastv4/core/config" + "github.com/sdslabs/beastv4/core/database" + "github.com/sdslabs/beastv4/core/manager" + "github.com/sdslabs/beastv4/pkg/auth" + "github.com/sdslabs/beastv4/pkg/cr" + "github.com/sdslabs/beastv4/pkg/remoteManager" +) + +const ( + maxExecRequestBytes = 32 << 10 + maxExecArguments = 64 + maxExecArgumentBytes = 16 << 10 + defaultExecTimeout = 30 + maxExecTimeout = 300 +) + +type execChallengeRequest struct { + Command []string `json:"command"` + InstanceID string `json:"instance_id,omitempty"` + TimeoutSeconds int `json:"timeout_seconds,omitempty"` +} + +type execChallengeResponse struct { + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + ExitCode int `json:"exit_code"` + Truncated bool `json:"truncated"` +} + +func (request *execChallengeRequest) validate() error { + if len(request.Command) == 0 || len(request.Command) > maxExecArguments { + return errors.New("command must contain between 1 and 64 arguments") + } + totalBytes := 0 + for index, argument := range request.Command { + if index == 0 && argument == "" { + return errors.New("command executable cannot be empty") + } + if strings.IndexByte(argument, 0) >= 0 { + return errors.New("command arguments cannot contain NUL bytes") + } + totalBytes += len(argument) + } + if totalBytes > maxExecArgumentBytes { + return errors.New("command arguments exceed 16 KiB") + } + if request.TimeoutSeconds == 0 { + request.TimeoutSeconds = defaultExecTimeout + } + if request.TimeoutSeconds < 1 || request.TimeoutSeconds > maxExecTimeout { + return errors.New("timeout_seconds must be between 1 and 300") + } + return nil +} + +func userCanExecChallenge(user database.User, challenge database.Challenge, maintainer bool) bool { + if user.Status != 0 { + return false + } + if user.Role == core.USER_ROLES["admin"] { + return true + } + if user.Role != core.USER_ROLES["author"] && user.Role != core.USER_ROLES["maintainer"] { + return false + } + return challenge.AuthorID == user.ID || maintainer +} + +func execChallengeHandler(c *gin.Context) { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxExecRequestBytes) + var request execChallengeRequest + if err := c.ShouldBindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, HTTPErrorResp{Error: "invalid exec request"}) + return + } + if err := request.validate(); err != nil { + c.JSON(http.StatusBadRequest, HTTPErrorResp{Error: err.Error()}) + return + } + + claimsValue, exists := c.Get("authClaims") + claims, validClaims := claimsValue.(*auth.CustomClaims) + if !exists || !validClaims { + c.JSON(http.StatusUnauthorized, HTTPErrorResp{Error: "verified authentication claims are required"}) + return + } + user, err := database.QueryFirstUserEntry("username", claims.User) + if err != nil || user.ID == 0 { + c.JSON(http.StatusForbidden, HTTPErrorResp{Error: "user is not authorized for container execution"}) + return + } + challenge, err := database.QueryFirstChallengeEntry("name", c.Param("name")) + if err != nil { + c.JSON(http.StatusInternalServerError, HTTPErrorResp{Error: "failed to query challenge"}) + return + } + if challenge.ID == 0 { + c.JSON(http.StatusNotFound, HTTPErrorResp{Error: "challenge not found"}) + return + } + maintainer, err := database.IsChallengeMaintainer(user.ID, challenge.ID) + if err != nil { + c.JSON(http.StatusInternalServerError, HTTPErrorResp{Error: "failed to authorize challenge access"}) + return + } + if !userCanExecChallenge(user, challenge, maintainer) { + c.JSON(http.StatusForbidden, HTTPErrorResp{Error: "challenge execution access denied"}) + return + } + if challenge.Status != core.DEPLOY_STATUS["deployed"] || challenge.Type == core.STATIC_CHALLENGE_TYPE_NAME { + c.JSON(http.StatusConflict, HTTPErrorResp{Error: "challenge does not have a running container"}) + return + } + + containerID := challenge.ContainerId + serverName := challenge.ServerDeployed + if challenge.Instanced { + if request.InstanceID == "" { + c.JSON(http.StatusBadRequest, HTTPErrorResp{Error: "instance_id is required for an instanced challenge"}) + return + } + instance, err := cache.GetInstance(request.InstanceID) + if err != nil || instance == nil || instance.ChallengeName != challenge.Name { + c.JSON(http.StatusNotFound, HTTPErrorResp{Error: "challenge instance not found"}) + return + } + containerID = instance.ContainerID + serverName = instance.ServerDeployed + } + if containerID == "" { + c.JSON(http.StatusConflict, HTTPErrorResp{Error: "challenge container is unavailable"}) + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), time.Duration(request.TimeoutSeconds)*time.Second) + defer cancel() + var result cr.ExecResult + server, found := config.Cfg.AvailableServers[serverName] + if !found || !server.Active { + c.JSON(http.StatusBadGateway, HTTPErrorResp{Error: "challenge worker is unavailable"}) + return + } + _ = manager.LogTransaction(challenge.Name, "EXEC", c.GetHeader("Authorization")) + if config.Cfg.UseLocalDockerDaemon(serverName) { + result, err = cr.ExecContainer(ctx, containerID, request.Command, cr.DefaultExecOutputLimit) + } else { + result, err = remoteManager.ExecContainerRemote(ctx, server, containerID, request.Command, cr.DefaultExecOutputLimit) + } + if err != nil { + if errors.Is(err, context.DeadlineExceeded) { + c.JSON(http.StatusGatewayTimeout, HTTPErrorResp{Error: "container command timed out"}) + } else { + c.JSON(http.StatusBadGateway, HTTPErrorResp{Error: "container command failed to execute"}) + } + return + } + c.JSON(http.StatusOK, execChallengeResponse{ + Stdout: result.Stdout, Stderr: result.Stderr, ExitCode: result.ExitCode, Truncated: result.Truncated, + }) +} diff --git a/api/exec_test.go b/api/exec_test.go new file mode 100644 index 00000000..62e22346 --- /dev/null +++ b/api/exec_test.go @@ -0,0 +1,47 @@ +package api + +import ( + "strings" + "testing" + + "github.com/sdslabs/beastv4/core" + "github.com/sdslabs/beastv4/core/database" + "github.com/sdslabs/beastv4/pkg/auth" + "gorm.io/gorm" +) + +func TestExecChallengeRequestValidation(t *testing.T) { + tests := []execChallengeRequest{ + {}, + {Command: []string{""}}, + {Command: []string{"echo", string([]byte{'a', 0, 'b'})}}, + {Command: []string{"echo"}, TimeoutSeconds: maxExecTimeout + 1}, + {Command: []string{strings.Repeat("a", maxExecArgumentBytes+1)}}, + } + for _, request := range tests { + if err := request.validate(); err == nil { + t.Fatalf("expected invalid request: %+v", request) + } + } + + valid := execChallengeRequest{Command: []string{"sh", "-lc", "id"}} + if err := valid.validate(); err != nil { + t.Fatal(err) + } + if valid.TimeoutSeconds != defaultExecTimeout { + t.Fatalf("default timeout = %d", valid.TimeoutSeconds) + } +} + +func TestUserCanExecChallenge(t *testing.T) { + challenge := database.Challenge{AuthorID: 1} + if !userCanExecChallenge(database.User{Model: gorm.Model{ID: 1}, AuthModel: auth.AuthModel{Role: core.USER_ROLES["author"]}}, challenge, false) { + t.Fatal("challenge author was denied") + } + if !userCanExecChallenge(database.User{Model: gorm.Model{ID: 2}, AuthModel: auth.AuthModel{Role: core.USER_ROLES["maintainer"]}}, challenge, true) { + t.Fatal("challenge maintainer was denied") + } + if userCanExecChallenge(database.User{Model: gorm.Model{ID: 3}, AuthModel: auth.AuthModel{Role: core.USER_ROLES["contestant"]}}, challenge, true) { + t.Fatal("contestant was allowed") + } +} diff --git a/api/main.go b/api/main.go index 1e86aed2..79004dd7 100644 --- a/api/main.go +++ b/api/main.go @@ -96,7 +96,7 @@ func RunBeastApiServer(ctx context.Context, port, defaultauthorpassword string, manager.Q = wpool.InitQueue(core.MAX_QUEUE_SIZE, nil) manager.Q.StartWorkers(&manager.Worker{}) - auth.Init(core.ITERATIONS, core.HASH_LENGTH, core.TIMEPERIOD, core.ISSUER, config.Cfg.JWTSecret, []string{core.USER_ROLES["author"]}, []string{core.USER_ROLES["admin"]}, []string{core.USER_ROLES["contestant"]}) + auth.Init(core.ITERATIONS, core.HASH_LENGTH, core.TIMEPERIOD, core.ISSUER, config.Cfg.JWTSecret, []string{core.USER_ROLES["author"], core.USER_ROLES["maintainer"]}, []string{core.USER_ROLES["admin"]}, []string{core.USER_ROLES["contestant"]}) remoteManager.Init() database.Init() cache.Init() diff --git a/api/router.go b/api/router.go index ef667fab..2f6a1f59 100644 --- a/api/router.go +++ b/api/router.go @@ -66,6 +66,7 @@ func initGinRouter() *gin.Engine { manageGroup.POST("/challenge/upload", manageUploadHandler) manageGroup.POST("/challenge/validateflag", validateFlagHandler) manageGroup.GET("/logs", challengeLogsHandler) + manageGroup.POST("/challenge/:name/exec", execChallengeHandler) } // Status route group diff --git a/cmd/beast/create_admin_author.go b/cmd/beast/create_admin_author.go index f9b616c1..6b8d9c45 100644 --- a/cmd/beast/create_admin_author.go +++ b/cmd/beast/create_admin_author.go @@ -14,7 +14,7 @@ import ( func createAuthorAdminPrereq() { config.InitConfig() - 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"]}) + auth.Init(core.ITERATIONS, core.HASH_LENGTH, core.TIMEPERIOD, core.ISSUER, config.Cfg.JWTSecret, []string{core.USER_ROLES["author"], core.USER_ROLES["maintainer"]}, []string{core.USER_ROLES["admin"]}, []string{core.USER_ROLES["contestant"]}) } var createAuthorCmd = &cobra.Command{ From cdd1c22553f6cb883192b5ec06165e90cfb34de9 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Thu, 16 Jul 2026 18:51:27 +0530 Subject: [PATCH 036/149] fix(challenges): preserve author and server ownership Signed-off-by: vibhatsu --- core/manager/health_check_test.go | 15 ++++++++++ core/manager/utils.go | 50 ++++++++++++++++++++++++------- 2 files changed, 55 insertions(+), 10 deletions(-) diff --git a/core/manager/health_check_test.go b/core/manager/health_check_test.go index 679618bf..04a752e9 100644 --- a/core/manager/health_check_test.go +++ b/core/manager/health_check_test.go @@ -3,6 +3,8 @@ package manager import ( "context" "testing" + + "github.com/sdslabs/beastv4/core/config" ) func TestBackgroundProbersRespectCancelledContext(t *testing.T) { @@ -12,3 +14,16 @@ func TestBackgroundProbersRespectCancelledContext(t *testing.T) { BeastHealthCheckProber(ctx, 1) InstanceCleanupProber(ctx) } + +func TestActiveLocalServerNameSupportsConfiguredAlias(t *testing.T) { + previous := config.Cfg + config.Cfg = &config.BeastConfig{AvailableServers: map[string]config.AvailableServer{ + "worker-local": {Host: "127.0.0.1", Active: true}, + }} + defer func() { config.Cfg = previous }() + + name, ok := activeLocalServerName() + if !ok || name != "worker-local" { + t.Fatalf("activeLocalServerName() = %q, %t", name, ok) + } +} diff --git a/core/manager/utils.go b/core/manager/utils.go index 25c23b0d..a9c38921 100644 --- a/core/manager/utils.go +++ b/core/manager/utils.go @@ -10,6 +10,7 @@ import ( "os" "path" "path/filepath" + "sort" "strings" "text/template" @@ -59,6 +60,23 @@ type ChallengePreview struct { Points uint } +func activeLocalServerName() (string, bool) { + if server, exists := cfg.Cfg.AvailableServers[core.LOCALHOST]; exists && server.Active && cfg.Cfg.UseLocalDockerDaemon(core.LOCALHOST) { + return core.LOCALHOST, true + } + names := make([]string, 0, len(cfg.Cfg.AvailableServers)) + for name := range cfg.Cfg.AvailableServers { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + if server := cfg.Cfg.AvailableServers[name]; server.Active && cfg.Cfg.UseLocalDockerDaemon(name) { + return name, true + } + } + return "", false +} + // This if the function which validates the challenge directory // which is provided as an arguments. This validtions includes // * A valid directory pointed by challengeDir @@ -443,13 +461,16 @@ func UpdateOrCreateChallengeDbEntry(challEntry *database.Challenge, config cfg.B if err != nil { return fmt.Errorf("error while querying user with email %s", user.Email) } + if u.ID == 0 || u.Status != 0 || (u.Role != core.USER_ROLES["author"] && u.Role != core.USER_ROLES["maintainer"] && u.Role != core.USER_ROLES["admin"]) { + return fmt.Errorf("maintainer %s is not an active manager account", user.Email) + } users[i] = &u } if userEntry.Email == "" { - // if defaultauthorpassword == "" { - // return fmt.Errorf("User with the given email does not exist : %v. You can pass q flag with password to autogenerate authors in this case.", config.Author.Email) - // } + if defaultauthorpassword == "" { + return fmt.Errorf("author %s does not exist and no creation password was provided", config.Author.Email) + } log.Infof("User with the given email does not exist : %v, creating this user", config.Author.Email) authModel, err := auth.CreateModel(config.Author.Email, defaultauthorpassword, core.USER_ROLES["author"]) if err != nil { @@ -464,13 +485,14 @@ func UpdateOrCreateChallengeDbEntry(challEntry *database.Challenge, config cfg.B if err != nil { return err } + userEntry = newUser log.Infof("Author with the email address %v is created", config.Author.Email) - // return nil } else { - if userEntry.Email != config.Author.Email && - (userEntry.Name != config.Author.Name || config.Author.Name == "") && - userEntry.Role != core.USER_ROLES["author"] { - return fmt.Errorf("ERROR, author details for %s did not match with the ones in database", userEntry.Email) + if userEntry.Status != 0 || (userEntry.Role != core.USER_ROLES["author"] && userEntry.Role != core.USER_ROLES["admin"]) { + return fmt.Errorf("author %s is not an active author account", userEntry.Email) + } + if config.Author.Name != "" && userEntry.Name != config.Author.Name { + return fmt.Errorf("author name for %s does not match the existing account", userEntry.Email) } } @@ -491,8 +513,16 @@ func UpdateOrCreateChallengeDbEntry(challEntry *database.Challenge, config cfg.B } availableServerHostname := core.LOCALHOST if config.Challenge.Metadata.Type != core.STATIC_CHALLENGE_TYPE_NAME { - availableServer, _ := remoteManager.ServerQueue.GetNextAvailableInstance() - availableServerHostname = availableServer.Name + availableServer, serverErr := remoteManager.ServerQueue.GetNextAvailableInstance() + if serverErr == nil { + availableServerHostname = availableServer.Name + } else { + localServerName, exists := activeLocalServerName() + if !exists { + return fmt.Errorf("no active challenge server is available") + } + availableServerHostname = localServerName + } } if config.Challenge.Metadata.Difficulty == "" { log.Debug("Setting difficulty to default(medium)") From 967baed440d03d9769d0c21040a66381cf4bf7cd Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Thu, 16 Jul 2026 18:57:24 +0530 Subject: [PATCH 037/149] fix(database): separate maintainers from submissions Signed-off-by: vibhatsu --- core/database/authorization_test.go | 33 ++++++++++++++++++++++++++++- core/database/challenges.go | 7 +++++- core/database/database.go | 10 +++++++-- core/database/maintainers.go | 21 ++++++++++++++++++ core/database/submission_test.go | 2 +- core/database/user.go | 4 ++-- 6 files changed, 70 insertions(+), 7 deletions(-) create mode 100644 core/database/maintainers.go diff --git a/core/database/authorization_test.go b/core/database/authorization_test.go index 70e2fc04..984e76a1 100644 --- a/core/database/authorization_test.go +++ b/core/database/authorization_test.go @@ -8,7 +8,7 @@ func TestIsChallengeMaintainer(t *testing.T) { user := createSubmissionTestUser(t, "author") challenge := createSubmissionTestChallenge(t, "challenge", 0, false) - if err := Db.Create(&UserChallenges{UserID: user.ID, ChallengeID: challenge.ID}).Error; err != nil { + if err := Db.Create(&ChallengeMaintainer{UserID: user.ID, ChallengeID: challenge.ID}).Error; err != nil { t.Fatal(err) } @@ -27,3 +27,34 @@ func TestIsChallengeMaintainer(t *testing.T) { t.Fatal("unrelated user was treated as maintainer") } } + +func TestMigrateChallengeMaintainersPreservesManagers(t *testing.T) { + cleanup := setupSubmissionTestDB(t) + defer cleanup() + + author := createSubmissionTestUser(t, "challenge-author") + if err := Db.Model(&author).Update("role", "author").Error; err != nil { + t.Fatal(err) + } + maintainer := createSubmissionTestUser(t, "challenge-maintainer") + if err := Db.Model(&maintainer).Update("role", "maintainer").Error; err != nil { + t.Fatal(err) + } + challenge := createSubmissionTestChallenge(t, "managed-challenge", 0, false) + if err := Db.Model(&challenge).Update("author_id", author.ID).Error; err != nil { + t.Fatal(err) + } + if err := Db.Create(&UserChallenges{UserID: maintainer.ID, ChallengeID: challenge.ID}).Error; err != nil { + t.Fatal(err) + } + + if err := MigrateChallengeMaintainers(); err != nil { + t.Fatal(err) + } + for _, userID := range []uint{author.ID, maintainer.ID} { + allowed, err := IsChallengeMaintainer(userID, challenge.ID) + if err != nil || !allowed { + t.Fatalf("manager %d was not migrated: allowed=%t err=%v", userID, allowed, err) + } + } +} diff --git a/core/database/challenges.go b/core/database/challenges.go index 31dd1d95..b9bfd133 100644 --- a/core/database/challenges.go +++ b/core/database/challenges.go @@ -63,7 +63,7 @@ type Challenge struct { MinPoints uint `gorm:"default:0"` Ports []Port Tags []*Tag `gorm:"many2many:tag_challenges;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` - Users []*User `gorm:"many2many:user_challenges;"` + Users []*User `gorm:"many2many:challenge_maintainers;"` ServerDeployed string `gorm:"type:varchar(64)"` Instanced bool `gorm:"not null;default:false"` InstanceExpiration int64 `gorm:"default:0"` @@ -83,6 +83,11 @@ type UserChallenges struct { Cheating bool `gorm:"not null;default:false"` } +type ChallengeMaintainer struct { + UserID uint `gorm:"primaryKey"` + ChallengeID uint `gorm:"primaryKey"` +} + type ChallengeAttempt struct { Id uint `json:"id"` UserId uint `json:"userId"` diff --git a/core/database/database.go b/core/database/database.go index c15d402b..ecffb56f 100644 --- a/core/database/database.go +++ b/core/database/database.go @@ -78,7 +78,10 @@ func Init() { if err := Db.SetupJoinTable(&Challenge{}, "Users", &UserChallenges{}); err != nil { log.Fatalf("Cannot create related models: %s", err) } - if err := Db.SetupJoinTable(&User{}, "Challenges", &UserChallenges{}); err != nil { + if err := Db.SetupJoinTable(&User{}, "Challenges", &ChallengeMaintainer{}); err != nil { + log.Fatalf("Cannot create related models: %s", err) + } + if err := Db.SetupJoinTable(&Challenge{}, "Users", &ChallengeMaintainer{}); err != nil { log.Fatalf("Cannot create related models: %s", err) } @@ -89,13 +92,16 @@ func Init() { // UserHint must be explicitly migrated since GORM's AutoMigrate on User only handles // the users table, not custom join table structs. Without this, the created_at and // challenge_id columns on user_hints won't be added to existing databases. - err := Db.AutoMigrate(&Challenge{}, &Transaction{}, &Port{}, &User{}, &UserChallenges{}, &Tag{}, &Notification{}, &Hint{}, &DynamicFlag{}, &DynamicFlagClaim{}, &DynamicScoreDirty{}, &OTP{}, &UserHint{}) + err := Db.AutoMigrate(&Challenge{}, &Transaction{}, &Port{}, &User{}, &UserChallenges{}, &ChallengeMaintainer{}, &Tag{}, &Notification{}, &Hint{}, &DynamicFlag{}, &DynamicFlagClaim{}, &DynamicScoreDirty{}, &OTP{}, &UserHint{}) if err != nil { log.Fatalf("failed to migrate database with error: %s", err) } if err := MigrateSubmissionGuards(); err != nil { log.Fatalf("failed to migrate submission guards with error: %s", err) } + if err := MigrateChallengeMaintainers(); err != nil { + log.Fatalf("failed to migrate challenge maintainers with error: %s", err) + } users, err := QueryUserEntries("email", core.DEFAULT_USER_EMAIL) if err != nil { diff --git a/core/database/maintainers.go b/core/database/maintainers.go new file mode 100644 index 00000000..02aaf254 --- /dev/null +++ b/core/database/maintainers.go @@ -0,0 +1,21 @@ +package database + +import "fmt" + +func MigrateChallengeMaintainers() error { + if Db == nil { + return fmt.Errorf("database is not initialized") + } + return Db.Exec(` +INSERT INTO challenge_maintainers (user_id, challenge_id) +SELECT users.id, challenges.id +FROM users +JOIN challenges ON challenges.author_id = users.id +UNION +SELECT uc.user_id, uc.challenge_id +FROM user_challenges uc +JOIN users ON users.id = uc.user_id +WHERE users.role IN ('author', 'maintainer', 'admin') +ON CONFLICT (user_id, challenge_id) DO NOTHING +`).Error +} diff --git a/core/database/submission_test.go b/core/database/submission_test.go index d5750a46..fb5e404d 100644 --- a/core/database/submission_test.go +++ b/core/database/submission_test.go @@ -52,7 +52,7 @@ func setupSubmissionTestDB(t *testing.T) func() { Db = testDB DBMux = &sync.Mutex{} - if err := Db.AutoMigrate(&Challenge{}, &User{}, &UserChallenges{}, &DynamicFlag{}, &DynamicFlagClaim{}, &DynamicScoreDirty{}); err != nil { + if err := Db.AutoMigrate(&Challenge{}, &User{}, &UserChallenges{}, &ChallengeMaintainer{}, &DynamicFlag{}, &DynamicFlagClaim{}, &DynamicScoreDirty{}); err != nil { t.Fatalf("auto migrate: %v", err) } if err := MigrateSubmissionGuards(); err != nil { diff --git a/core/database/user.go b/core/database/user.go index 02b9fec0..26c2e54a 100644 --- a/core/database/user.go +++ b/core/database/user.go @@ -15,7 +15,7 @@ type User struct { gorm.Model auth.AuthModel - Challenges []*Challenge `gorm:"many2many:user_challenges;"` + Challenges []*Challenge `gorm:"many2many:challenge_maintainers;"` Name string `gorm:"not null"` Email string `gorm:"non null;unique"` Status uint `gorm:"not null;default:0"` // 0 for unbanned, 1 for banned @@ -157,7 +157,7 @@ func IsChallengeMaintainer(userID, challengeID uint) (bool, error) { var count int64 DBMux.Lock() defer DBMux.Unlock() - err := Db.Table("user_challenges"). + err := Db.Table("challenge_maintainers"). Where("user_id = ? AND challenge_id = ?", userID, challengeID). Count(&count).Error return count > 0, err From 6b0dbb8e8c0e3ff28f78697299337199ff5a0cbf Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Thu, 16 Jul 2026 19:00:12 +0530 Subject: [PATCH 038/149] fix(challenges): persist manager relations atomically Signed-off-by: vibhatsu --- core/database/authorization_test.go | 18 +++++++++++++++++- core/database/maintainers.go | 27 +++++++++++++++++++++++++++ core/manager/utils.go | 6 +++--- 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/core/database/authorization_test.go b/core/database/authorization_test.go index 984e76a1..40f94f9e 100644 --- a/core/database/authorization_test.go +++ b/core/database/authorization_test.go @@ -1,6 +1,22 @@ package database -import "testing" +import ( + "testing" + + "gorm.io/gorm" +) + +func TestSetChallengeRelationsRequiresPersistedModels(t *testing.T) { + if err := SetChallengeRelations(nil, nil, nil); err == nil { + t.Fatal("expected missing challenge error") + } + if err := SetChallengeRelations(&Challenge{}, nil, nil); err == nil { + t.Fatal("expected unpersisted challenge error") + } + if err := SetChallengeRelations(&Challenge{Model: gorm.Model{ID: 1}}, nil, []*User{{}}); err == nil { + t.Fatal("expected unpersisted manager error") + } +} func TestIsChallengeMaintainer(t *testing.T) { cleanup := setupSubmissionTestDB(t) diff --git a/core/database/maintainers.go b/core/database/maintainers.go index 02aaf254..4198ab24 100644 --- a/core/database/maintainers.go +++ b/core/database/maintainers.go @@ -19,3 +19,30 @@ WHERE users.role IN ('author', 'maintainer', 'admin') ON CONFLICT (user_id, challenge_id) DO NOTHING `).Error } + +func SetChallengeRelations(challenge *Challenge, tags []*Tag, users []*User) error { + if challenge == nil || challenge.ID == 0 { + return fmt.Errorf("persisted challenge is required") + } + for _, user := range users { + if user == nil || user.ID == 0 { + return fmt.Errorf("persisted challenge manager is required") + } + } + + DBMux.Lock() + defer DBMux.Unlock() + tx := Db.Begin() + if tx.Error != nil { + return tx.Error + } + if err := tx.Model(challenge).Association("Tags").Replace(tags); err != nil { + tx.Rollback() + return fmt.Errorf("set challenge tags: %w", err) + } + if err := tx.Model(challenge).Association("Users").Replace(users); err != nil { + tx.Rollback() + return fmt.Errorf("set challenge managers: %w", err) + } + return tx.Commit().Error +} diff --git a/core/manager/utils.go b/core/manager/utils.go index a9c38921..079da65e 100644 --- a/core/manager/utils.go +++ b/core/manager/utils.go @@ -577,9 +577,9 @@ func UpdateOrCreateChallengeDbEntry(challEntry *database.Challenge, config cfg.B } } - database.Db.Model(challEntry).Association("Tags").Append(tags) - - database.Db.Model(challEntry).Association("Users").Append(users) + if err := database.SetChallengeRelations(challEntry, tags, users); err != nil { + return fmt.Errorf("set challenge relations: %w", err) + } } allocatedPorts, err := database.GetAllocatedPorts(*challEntry) From 0dcf8fcdfd9f6249e14630d8eda45dac196eed37 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Thu, 16 Jul 2026 19:03:40 +0530 Subject: [PATCH 039/149] fix(remote): construct quoted SSH commands Signed-off-by: vibhatsu --- pkg/remoteManager/exec_test.go | 13 +++++++ pkg/remoteManager/ssh.go | 65 +++++++++++++++++++++++++++++++++- 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/pkg/remoteManager/exec_test.go b/pkg/remoteManager/exec_test.go index 38654850..427d7582 100644 --- a/pkg/remoteManager/exec_test.go +++ b/pkg/remoteManager/exec_test.go @@ -20,3 +20,16 @@ func TestCappedCommandBufferDiscardsExcess(t *testing.T) { t.Fatalf("unexpected buffer state: written=%d value=%q truncated=%t", written, buffer.buffer.String(), buffer.truncated) } } + +func TestEnvironmentNamesAreConstrained(t *testing.T) { + for _, name := range []string{"PORT", "INSTANCE_PORT_1", "_PRIVATE"} { + if !environmentNamePattern.MatchString(name) { + t.Fatalf("valid environment name rejected: %q", name) + } + } + for _, name := range []string{"BAD-NAME", "NAME;id", "1PORT"} { + if environmentNamePattern.MatchString(name) { + t.Fatalf("invalid environment name accepted: %q", name) + } + } +} diff --git a/pkg/remoteManager/ssh.go b/pkg/remoteManager/ssh.go index 3eaadf6d..baaf4bc1 100644 --- a/pkg/remoteManager/ssh.go +++ b/pkg/remoteManager/ssh.go @@ -4,6 +4,9 @@ import ( "errors" "fmt" "io/ioutil" + "regexp" + "sort" + "strings" "sync" "github.com/sdslabs/beastv4/core/config" @@ -19,6 +22,22 @@ type LoadBalancerQueue struct { var ServerQueue LoadBalancerQueue +var environmentNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + +type RemoteCommandError struct { + ExitStatus int + Output string + Err error +} + +func (commandError *RemoteCommandError) Error() string { + return fmt.Sprintf("remote command exited with status %d: %v", commandError.ExitStatus, commandError.Err) +} + +func (commandError *RemoteCommandError) Unwrap() error { + return commandError.Err +} + // Returns a Queue of all available server to achive Round-Robin load balancing func NewLoadBalancerQueue() LoadBalancerQueue { return LoadBalancerQueue{} @@ -89,13 +108,57 @@ func RunCommandOnServer(server config.AvailableServer, cmd string) (string, erro output, err := session.CombinedOutput(cmd) if err != nil { - return "", fmt.Errorf("failed to execute command: %s\nOutput: %s", err, output) + exitStatus := -1 + var exitError *ssh.ExitError + if errors.As(err, &exitError) { + exitStatus = exitError.ExitStatus() + } + return string(output), &RemoteCommandError{ExitStatus: exitStatus, Output: string(output), Err: err} } log.Debugf("Command output for cmd %s : %s\n", cmd, output) return string(output), nil } +func RunArgsOnServer(server config.AvailableServer, arguments ...string) (string, error) { + if len(arguments) == 0 { + return "", fmt.Errorf("remote command arguments are empty") + } + return RunCommandOnServer(server, "exec "+shellJoin(arguments)) +} + +func RunArgsInDirOnServer(server config.AvailableServer, directory string, arguments ...string) (string, error) { + if directory == "" || len(arguments) == 0 { + return "", fmt.Errorf("remote directory and command arguments are required") + } + command := "cd -- " + shellQuote(directory) + " && exec " + shellJoin(arguments) + return RunCommandOnServer(server, command) +} + +func RunArgsWithEnvOnServer(server config.AvailableServer, environment map[string]string, arguments ...string) (string, error) { + if len(arguments) == 0 { + return "", fmt.Errorf("remote command arguments are empty") + } + keys := make([]string, 0, len(environment)) + for key := range environment { + if !environmentNamePattern.MatchString(key) { + return "", fmt.Errorf("invalid environment variable name %q", key) + } + keys = append(keys, key) + } + sort.Strings(keys) + assignments := make([]string, 0, len(keys)) + for _, key := range keys { + assignments = append(assignments, key+"="+shellQuote(environment[key])) + } + command := strings.Join(assignments, " ") + if command != "" { + command += " " + } + command += "exec " + shellJoin(arguments) + return RunCommandOnServer(server, command) +} + // Creates an SSH client to connect to the remote server. func CreateSSHClient(remoteServer config.AvailableServer) (*ssh.Client, error) { if !remoteServer.Active { From 516605f1367c32c9e06c3da03cc01a7a336d626a Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Thu, 16 Jul 2026 19:18:05 +0530 Subject: [PATCH 040/149] fix(remote): pass Docker container arguments safely Signed-off-by: vibhatsu --- pkg/remoteManager/container.go | 203 +++++++++++++++------------------ 1 file changed, 93 insertions(+), 110 deletions(-) diff --git a/pkg/remoteManager/container.go b/pkg/remoteManager/container.go index 25ec6360..0ae8e953 100644 --- a/pkg/remoteManager/container.go +++ b/pkg/remoteManager/container.go @@ -5,6 +5,8 @@ import ( "encoding/json" "fmt" "path/filepath" + "sort" + "strconv" "strings" "github.com/docker/docker/api/types" @@ -12,60 +14,57 @@ import ( "github.com/sdslabs/beastv4/core/config" "github.com/sdslabs/beastv4/core/database" "github.com/sdslabs/beastv4/pkg/cr" - "github.com/sdslabs/beastv4/utils" log "github.com/sirupsen/logrus" "gorm.io/gorm" ) func CreateContainerFromImageRemote(containerConfig cr.CreateContainerConfig, server config.AvailableServer) (string, error) { - var containerName, containerEnv, exposedPorts, portMap, cpuShareLimit, cpuLimit, memoryLimit, pidLimit, imageID, mountBindings string + arguments := []string{"docker", "run", "-d"} if containerConfig.ContainerName != "" { - containerName = fmt.Sprintf("--name %s ", containerConfig.ContainerName) + arguments = append(arguments, "--name", containerConfig.ContainerName) } for _, envVar := range containerConfig.ContainerEnv { - containerEnv += fmt.Sprintf("--env %s ", envVar) + arguments = append(arguments, "--env", envVar) } for _, portMapping := range containerConfig.PortMapping { - portMap += fmt.Sprintf("-p 0.0.0.0:%d:%d/%s ", portMapping.HostPort, portMapping.ContainerPort, containerConfig.TrafficType()) - exposedPorts += fmt.Sprintf("--expose %d ", portMapping.ContainerPort) + arguments = append(arguments, + "--publish", fmt.Sprintf("0.0.0.0:%d:%d/%s", portMapping.HostPort, portMapping.ContainerPort, containerConfig.TrafficType()), + "--expose", strconv.FormatUint(uint64(portMapping.ContainerPort), 10)) } if containerConfig.CPUShares != 0 { - cpuShareLimit = fmt.Sprintf("--cpu-shares %d ", containerConfig.CPUShares) + arguments = append(arguments, "--cpu-shares", strconv.FormatInt(containerConfig.CPUShares, 10)) } if containerConfig.CPUsLimit != 0 { - cpuLimit = fmt.Sprintf("--cpus %f ", containerConfig.CPUsLimit) + arguments = append(arguments, "--cpus", strconv.FormatFloat(float64(containerConfig.CPUsLimit), 'f', -1, 32)) } if containerConfig.Memory != 0 { - memoryLimit = fmt.Sprintf("--memory %d ", containerConfig.Memory) + arguments = append(arguments, "--memory", strconv.FormatInt(containerConfig.Memory, 10)) } if containerConfig.PidsLimit != 0 { - pidLimit = fmt.Sprintf("--pids-limit %d ", containerConfig.PidsLimit) - } - if containerConfig.ImageId != "" { - imageID = containerConfig.ImageId - } - for src, dest := range containerConfig.MountsMap { - mountBindings += fmt.Sprintf("--mount type=bind,source=%s,target=%s ", src, dest) - } - dockerCommand := fmt.Sprintf("docker run -d %s %s %s %s %s %s %s %s %s %s", containerName, containerEnv, exposedPorts, mountBindings, cpuShareLimit, cpuLimit, memoryLimit, pidLimit, portMap, imageID) - // fmt.Printf("%s, %s, %s, %s\n", containerName, containerEnv, exposedPorts, portMap) - // dockerCommand := fmt.Sprintf("docker run \\ - // --name \\ - // --env KEY1=value1 --env KEY2=value2 \\ - // --expose \\ - // --mount type=bind,source=,target= \\ - // --cpus= \\ - // --memory= \\ - // --pids-limit \\ - // -p 0.0.0.0:: \\ - // " - // ); - output, err := RunCommandOnServer(server, dockerCommand) + arguments = append(arguments, "--pids-limit", strconv.FormatInt(containerConfig.PidsLimit, 10)) + } + if containerConfig.ImageId == "" { + return "", fmt.Errorf("image ID is required") + } + mountSources := make([]string, 0, len(containerConfig.MountsMap)) + for source := range containerConfig.MountsMap { + mountSources = append(mountSources, source) + } + sort.Strings(mountSources) + for _, source := range mountSources { + arguments = append(arguments, "--mount", fmt.Sprintf("type=bind,source=%s,target=%s", source, containerConfig.MountsMap[source])) + } + arguments = append(arguments, containerConfig.ImageId) + output, err := RunArgsOnServer(server, arguments...) if err != nil { return "", fmt.Errorf("failed to create container: %s\nOutput: %s", err, output) } - log.Println(output[:12]) - return strings.TrimSpace(output[:12]), nil + containerID := strings.TrimSpace(output) + if len(containerID) < 12 { + return "", fmt.Errorf("docker returned invalid container ID %q", containerID) + } + log.Println(containerID[:12]) + return containerID[:12], nil } // Stops and remove cremote container. @@ -87,14 +86,12 @@ func StopAndRemoveContainerRemote(containerId string, server config.AvailableSer return fmt.Errorf("no container with container id %s found", containerId) } } - stopCommand := fmt.Sprintf("docker stop %s", containerId) - if _, err := RunCommandOnServer(server, stopCommand); err != nil { + if _, err := RunArgsOnServer(server, "docker", "stop", containerId); err != nil { return fmt.Errorf("failed to stop container on server %s : %w", server.Host, err) } log.Debugf("Stopped container with ID %s on %s", containerId, server.Host) - removeCommand := fmt.Sprintf("docker rm --force %s", containerId) - if _, err := RunCommandOnServer(server, removeCommand); err != nil { + if _, err := RunArgsOnServer(server, "docker", "rm", "--force", containerId); err != nil { return fmt.Errorf("failed to remove container on server %s : %w", server.Host, err) } log.Printf("Removed container with ID %s on %s", containerId, server.Host) @@ -104,83 +101,71 @@ func StopAndRemoveContainerRemote(containerId string, server config.AvailableSer // Function searches containers based on the filter map on all remote servers func SearchContainerByFilterRemote(filterMap map[string]string, server config.AvailableServer) ([]types.Container, error) { - filterArgs := "" - containers := []types.Container{} - var output string - var err error - for key, val := range filterMap { - filterArgs += fmt.Sprintf("--filter='%s=%s' ", key, val) - } - for serverDeployed, server := range config.Cfg.AvailableServers { - if server.Active { - if !config.Cfg.UseLocalDockerDaemon(serverDeployed) { - output, err = RunCommandOnServer(server, fmt.Sprintf("docker ps -a %s --format '{{.ID}}'", filterArgs)) - if err != nil { - return []types.Container{}, err - } - for _, line := range bytes.Split([]byte(output), []byte("\n")) { - if len(line) > 0 { - containers = append(containers, types.Container{ID: string(line)}) - } - } - } - } - } - - return containers, nil + return searchContainersRemote(filterMap, server, true) } // Function searches for running containers based on the filter map on all remote server func SearchRunningContainerByFilterRemote(filterMap map[string]string, server config.AvailableServer) ([]types.Container, error) { - filterArgs := "" - containers := []types.Container{} - var output string - var err error - for key, val := range filterMap { - filterArgs += fmt.Sprintf("--filter='%s=%s' ", key, val) - } - for serverDeployed, server := range config.Cfg.AvailableServers { - if server.Active { - if !config.Cfg.UseLocalDockerDaemon(serverDeployed) { - output, err = RunCommandOnServer(server, fmt.Sprintf("docker ps %s --format '{{.ID}}'", filterArgs)) - if err != nil { - return []types.Container{}, err - } - for _, line := range bytes.Split([]byte(output), []byte("\n")) { - if len(line) > 0 { - containers = append(containers, types.Container{ID: string(line)}) - } - } + return searchContainersRemote(filterMap, server, false) +} + +func searchContainersRemote(filterMap map[string]string, server config.AvailableServer, all bool) ([]types.Container, error) { + arguments := []string{"docker", "ps"} + if all { + arguments = append(arguments, "--all") + } + keys := make([]string, 0, len(filterMap)) + for key := range filterMap { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + arguments = append(arguments, "--filter", key+"="+filterMap[key]) + } + arguments = append(arguments, "--format", "{{json .}}") + output, err := RunArgsOnServer(server, arguments...) + if err != nil { + return nil, err + } + type containerRow struct { + ID string + Names string + Labels string + } + containers := make([]types.Container, 0) + for _, line := range bytes.Split([]byte(output), []byte("\n")) { + if len(bytes.TrimSpace(line)) == 0 { + continue + } + var row containerRow + if err := json.Unmarshal(line, &row); err != nil { + return nil, fmt.Errorf("parse remote container list: %w", err) + } + labels := make(map[string]string) + for _, label := range strings.Split(row.Labels, ",") { + key, value, found := strings.Cut(label, "=") + if found { + labels[key] = value } } + containers = append(containers, types.Container{ID: row.ID, Names: []string{row.Names}, Labels: labels}) } - return containers, nil } // Get Containers stdout, stderr logs func GetContainerStdLogsRemote(containerID string, server config.AvailableServer) (*cr.Log, error) { - stdoutCmd := fmt.Sprintf("docker logs --details --stdout %s", containerID) - stderrCmd := fmt.Sprintf("docker logs --details --stderr %s", containerID) - - stdout, err := RunCommandOnServer(server, stdoutCmd) + stdout, err := RunArgsOnServer(server, "docker", "logs", "--details", containerID) if err != nil { return nil, fmt.Errorf("error fetching stdout logs: %w", err) } - stderr, err := RunCommandOnServer(server, stderrCmd) - if err != nil { - return nil, fmt.Errorf("error fetching stderr logs: %w", err) - } - - return &cr.Log{Stdout: stdout, Stderr: stderr}, nil + return &cr.Log{Stdout: stdout}, nil } // Get live logs of container func ShowLiveContainerLogsRemote(containerID string, server config.AvailableServer) error { - command := fmt.Sprintf("docker logs --details --follow %s", containerID) - - output, err := RunCommandOnServer(server, command) + output, err := RunArgsOnServer(server, "docker", "logs", "--details", "--follow", containerID) if err != nil { return fmt.Errorf("error streaming live logs: %w", err) } @@ -191,9 +176,7 @@ func ShowLiveContainerLogsRemote(containerID string, server config.AvailableServ // Commit container on remote server func CommitContainerRemote(containerID string, server config.AvailableServer) (string, error) { - command := fmt.Sprintf("docker commit %s", containerID) - - output, err := RunCommandOnServer(server, command) + output, err := RunArgsOnServer(server, "docker", "commit", containerID) if err != nil { return "", fmt.Errorf("error committing container: %w", err) } @@ -205,9 +188,12 @@ func DeployContainerFromComposeRemote(challengeName string, projectName string, extractDir := filepath.Join(stagedDir, challengeName) composeFile := filepath.Join(extractDir, composeFileName) - upCommand := fmt.Sprintf("%s docker compose -f %s -p %s up -d", utils.PortMappingToEnvironmentVariable(ports), composeFile, projectName) + environment := make(map[string]string, len(ports)) + for variable, port := range ports { + environment[variable] = strconv.FormatUint(uint64(port), 10) + } log.Debugf("Deploying challenge %s using docker compose remotely with project %s and file %s", challengeName, projectName, composeFileName) - upOutput, err := RunCommandOnServer(server, upCommand) + upOutput, err := RunArgsWithEnvOnServer(server, environment, "docker", "compose", "-f", composeFile, "-p", projectName, "up", "-d") if err != nil { log.Errorf("docker compose up failed for challenge %s. Output:\n%s", challengeName, upOutput) return "", fmt.Errorf("error while running docker compose up on remote: %v", err) @@ -228,9 +214,8 @@ func DeployContainerFromComposeRemote(challengeName string, projectName string, } func validateAllComposeServicesRunningRemote(projectName, challengeName string, server config.AvailableServer) error { - psCommand := fmt.Sprintf("docker compose -p %s ps --format json", projectName) - log.Debugf("Verifying docker compose services for challenge %s: %s", challengeName, psCommand) - psOutput, err := RunCommandOnServer(server, psCommand) + log.Debugf("Verifying docker compose services for challenge %s", challengeName) + psOutput, err := RunArgsOnServer(server, "docker", "compose", "-p", projectName, "ps", "--format", "json") if err != nil { log.Errorf("docker compose ps failed for challenge %s. Output:\n%s", challengeName, psOutput) return fmt.Errorf("error while verifying docker compose services on remote: %v", err) @@ -287,16 +272,16 @@ func validateAllComposeServicesRunningRemote(projectName, challengeName string, // gets the first container ID from a compose project on remote func getPrimaryComposeContainerIdRemote(projectName string, server config.AvailableServer) (string, error) { - psCommand := fmt.Sprintf("docker compose -p %s ps -q | head -1", projectName) - output, err := RunCommandOnServer(server, psCommand) + output, err := RunArgsOnServer(server, "docker", "compose", "-p", projectName, "ps", "-q") if err != nil { return "", fmt.Errorf("failed to get container IDs on remote: %v", err) } - containerId := strings.TrimSpace(output) - if containerId == "" { + containerIDs := strings.Fields(output) + if len(containerIDs) == 0 { return "", fmt.Errorf("no containers found for project %s on remote", projectName) } + containerId := containerIDs[0] // Return first 12 characters if len(containerId) >= 12 { @@ -308,8 +293,7 @@ func getPrimaryComposeContainerIdRemote(projectName string, server config.Availa // ComposeDownProjectRemote runs docker compose down for an explicit -p project name. func ComposeDownProjectRemote(projectName string, server config.AvailableServer) error { log.Debugf("Stopping docker compose project %s on remote", projectName) - downCommand := fmt.Sprintf("docker compose -p %s down", projectName) - downOutput, err := RunCommandOnServer(server, downCommand) + downOutput, err := RunArgsOnServer(server, "docker", "compose", "-p", projectName, "down") if err != nil { return fmt.Errorf("docker compose down failed for project %s on remote: %v. Output: %s", projectName, err, downOutput) } @@ -320,8 +304,7 @@ func ComposeDownProjectRemote(projectName string, server config.AvailableServer) // ComposePurgeProjectRemote purges a compose project by explicit -p name (shared or instanced). func ComposePurgeProjectRemote(projectName string, server config.AvailableServer) error { log.Debugf("Purging docker compose project %s on remote", projectName) - purgeCommand := fmt.Sprintf("docker compose -p %s down --remove-orphans --volumes --rmi all", projectName) - purgeOutput, err := RunCommandOnServer(server, purgeCommand) + purgeOutput, err := RunArgsOnServer(server, "docker", "compose", "-p", projectName, "down", "--remove-orphans", "--volumes", "--rmi", "all") if err != nil { return fmt.Errorf("docker compose purge failed for project %s on remote: %v. Output: %s", projectName, err, purgeOutput) } From 924c5ec700414aa78521505afc6ba45699f3a8ed Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Thu, 16 Jul 2026 19:49:26 +0530 Subject: [PATCH 041/149] fix(remote): use home-relative worker paths Signed-off-by: vibhatsu --- core/constants.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/constants.go b/core/constants.go index ff99d822..297b5576 100644 --- a/core/constants.go +++ b/core/constants.go @@ -35,7 +35,7 @@ const ( //names DELIMITER string = "::::" LOCALHOST string = "localhost" LOCALHOST_IP string = "127.0.0.1" - BEAST_REMOTE_GLOBAL_DIR string = "~/.beast" // This should always be used for remote only. + BEAST_REMOTE_GLOBAL_DIR string = ".beast" // Relative to the remote SSH user's home directory. DOCKER_PID string = "/var/run/docker.pid" BEAST_GRAPH_CACHE string = "graph_cache.json" BEAST_LEADERBOARD_CACHE string = "leaderboard.json" From e5dfa3b9668f4c36900b397c2c28dd6766060432 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Thu, 16 Jul 2026 19:54:58 +0530 Subject: [PATCH 042/149] fix(remote): quote staging and build commands Signed-off-by: vibhatsu --- pkg/remoteManager/file.go | 58 ++++++++++++++++++--------------------- 1 file changed, 26 insertions(+), 32 deletions(-) diff --git a/pkg/remoteManager/file.go b/pkg/remoteManager/file.go index 8ffe4cd7..99793d1b 100644 --- a/pkg/remoteManager/file.go +++ b/pkg/remoteManager/file.go @@ -12,21 +12,14 @@ import ( "github.com/sdslabs/beastv4/core/config" "github.com/sdslabs/beastv4/core/database" "github.com/sdslabs/beastv4/utils" - log "github.com/sirupsen/logrus" ) func ValidateFileRemoteExists(server config.AvailableServer, stagedChallengePath string) error { - output, err := RunCommandOnServer(server, fmt.Sprintf("test -e %s&&echo exists||echo not exists", stagedChallengePath)) + _, err := RunArgsOnServer(server, "test", "-e", stagedChallengePath) if err != nil { - log.Errorf("Error while checking file existence: %s\n", err) - return err - } - log.Printf("Output: %s\n", output) - if strings.TrimSpace(output) == "exists" { - return nil - } else { return fmt.Errorf("path %s does not exist in remote server %s", stagedChallengePath, server.Host) } + return nil } // Rsync any file to other servers for chall deployment @@ -36,8 +29,11 @@ func RsyncFileToServer(server config.AvailableServer, localFilePath, remoteFileP return fmt.Errorf("file %s does not exist: %s", localFilePath, err) } fmt.Printf("Rsyncing %s to %s:%s\n", localFilePath, server.Host, remoteFilePath) - cmd := exec.Command("rsync", "-avz", - "-e", fmt.Sprintf("ssh -i %s", server.SSHKeyPath), + remoteShell := "ssh -i " + shellQuote(server.SSHKeyPath) + + " -o " + shellQuote("UserKnownHostsFile="+server.KnownHostsFile) + + " -o StrictHostKeyChecking=yes" + cmd := exec.Command("rsync", "-az", "--protect-args", + "-e", remoteShell, "--", localFilePath, fmt.Sprintf("%s@%s:%s", server.Username, server.Host, remoteFilePath)) var out bytes.Buffer @@ -68,7 +64,7 @@ func StageChallRemote(server config.AvailableServer, challenge database.Challeng // } // Rsync the challenge files to the server - err = RsyncFileToServer(server, fmt.Sprintf("%s/%s", stagingDirPath, challenge.Name), stagingRemoteDirPath) + err = RsyncFileToServer(server, filepath.Join(stagingDirPath, challenge.Name), stagingRemoteDirPath) if err != nil { return fmt.Errorf("failed to rsync challenge files: %s", err) } @@ -79,27 +75,25 @@ func StageChallRemote(server config.AvailableServer, challenge database.Challeng // BuildImageFromTarContextRemote builds a Docker image from the tar context on the remote server. func BuildImageFromTarContextRemote(challengeName string, imageTag string, stagedDir string, server config.AvailableServer) ([]byte, string, error) { remoteExtractPath := filepath.Join(core.BEAST_REMOTE_GLOBAL_DIR, core.BEAST_STAGING_DIR, challengeName, challengeName) - _, err := RunCommandOnServer(server, fmt.Sprintf("mkdir -p %s && tar -xf %s -C %s", remoteExtractPath, stagedDir, remoteExtractPath)) + _, err := RunArgsOnServer(server, "mkdir", "-p", remoteExtractPath) + if err == nil { + _, err = RunArgsOnServer(server, "tar", "-xf", stagedDir, "-C", remoteExtractPath) + } if err != nil { return []byte{}, "", fmt.Errorf("failed to extract tar: %s", err) } projectName := utils.ProjectNameNotInstanced(challengeName) - dockerBuildCmd := fmt.Sprintf("cd %s && docker build -t %s "+ - "--label beast.challenge=%s "+ - "--label com.sdslabs.beast.project=%s "+ - "--label com.docker.compose.project=%s .", - remoteExtractPath, imageTag, challengeName, projectName, projectName) - output, err := RunCommandOnServer(server, dockerBuildCmd) + output, err := RunArgsInDirOnServer(server, remoteExtractPath, + "docker", "build", "-t", imageTag, + "--label", "beast.challenge="+challengeName, + "--label", "com.sdslabs.beast.project="+projectName, + "--label", "com.docker.compose.project="+projectName, ".") if err != nil { return []byte{}, "", fmt.Errorf("failed to build docker image: %s\nOutput: %s", err, output) } - getImageIDCmd := fmt.Sprintf( - "docker images --format '{{.Repository}} {{.ID}}' | grep %s | awk '{print $2}'", - imageTag, - ) - imageID, err := RunCommandOnServer(server, getImageIDCmd) + imageID, err := RunArgsOnServer(server, "docker", "image", "inspect", "--format", "{{.Id}}", imageTag) if err != nil { - log.Fatalf("Failed to retrieve Docker image ID: %v", err) + return []byte(output), "", fmt.Errorf("retrieve Docker image ID: %w", err) } if imageID == "" { return []byte{}, "", fmt.Errorf("failed to retrieve Docker image ID") @@ -109,20 +103,20 @@ func BuildImageFromTarContextRemote(challengeName string, imageTag string, stage func BuildImagesFromComposeRemote(challengeName, imageTag, stagedDir string, server config.AvailableServer, noCache bool) ([]byte, error) { remoteExtractPath := filepath.Join(core.BEAST_REMOTE_GLOBAL_DIR, core.BEAST_STAGING_DIR, challengeName, challengeName) - _, err := RunCommandOnServer(server, fmt.Sprintf("mkdir -p %s && tar -xf %s -C %s", remoteExtractPath, stagedDir, remoteExtractPath)) + _, err := RunArgsOnServer(server, "mkdir", "-p", remoteExtractPath) + if err == nil { + _, err = RunArgsOnServer(server, "tar", "-xf", stagedDir, "-C", remoteExtractPath) + } if err != nil { return []byte{}, fmt.Errorf("failed to extract tar: %s", err) } - cmdBase := "docker compose build" + arguments := []string{"docker", "compose", "build"} if noCache { - cmdBase += " --no-cache" + arguments = append(arguments, "--no-cache") } // Note: docker compose build does not support --label flag // Labels are automatically added to containers during 'docker compose up -p ' - dockerComposeBuildCmd := fmt.Sprintf("cd %s && %s", remoteExtractPath, cmdBase) - - // Execute the command on the remote server - output, err := RunCommandOnServer(server, dockerComposeBuildCmd) + output, err := RunArgsInDirOnServer(server, remoteExtractPath, arguments...) if err != nil { return []byte(output), fmt.Errorf("failed to build docker compose images remotely: %s\nOutput: %s", err, output) } From 24a8890bc23d9b403129899c41832e9ff14d67c9 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Thu, 16 Jul 2026 19:59:03 +0530 Subject: [PATCH 043/149] fix(remote): eliminate raw SSH command callers Signed-off-by: vibhatsu --- core/utils/cleanup.go | 4 +--- pkg/remoteManager/file.go | 5 ----- pkg/remoteManager/health_check.go | 5 ++--- pkg/remoteManager/image.go | 14 +++++--------- pkg/remoteManager/init.go | 3 +-- pkg/remoteManager/ssh.go | 8 ++++---- 6 files changed, 13 insertions(+), 26 deletions(-) diff --git a/core/utils/cleanup.go b/core/utils/cleanup.go index b30afd3f..dc849fe7 100644 --- a/core/utils/cleanup.go +++ b/core/utils/cleanup.go @@ -70,9 +70,7 @@ func CleanupChallengeContainers(chall *database.Challenge, config cfg.BeastChall if !cfg.Cfg.UseLocalDockerDaemon(chall.ServerDeployed) { server := cfg.Cfg.AvailableServers[chall.ServerDeployed] - downCommand := fmt.Sprintf("docker compose -p %s down", projectName) - _, err := remoteManager.RunCommandOnServer(server, downCommand) - if err != nil { + if err := remoteManager.ComposeDownProjectRemote(projectName, server); err != nil { log.Errorf("Error running docker compose down on remote: %v", err) return err } diff --git a/pkg/remoteManager/file.go b/pkg/remoteManager/file.go index 99793d1b..4d63f604 100644 --- a/pkg/remoteManager/file.go +++ b/pkg/remoteManager/file.go @@ -58,11 +58,6 @@ func StageChallRemote(server config.AvailableServer, challenge database.Challeng stagingDirPath := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_STAGING_DIR) stagingRemoteDirPath := filepath.Join(core.BEAST_REMOTE_GLOBAL_DIR, core.BEAST_STAGING_DIR) - // err = RunCommandOnServer(server, fmt.Sprintf("mkdir -p %s/%s", remoteStagingDir, challenge.Name)) - // if err != nil { - // return fmt.Errorf("failed to create directory: %s", err) - // } - // Rsync the challenge files to the server err = RsyncFileToServer(server, filepath.Join(stagingDirPath, challenge.Name), stagingRemoteDirPath) if err != nil { diff --git a/pkg/remoteManager/health_check.go b/pkg/remoteManager/health_check.go index 659dd7ea..42b67d58 100644 --- a/pkg/remoteManager/health_check.go +++ b/pkg/remoteManager/health_check.go @@ -103,7 +103,7 @@ func CleanupOrphanedComposeInstancesOnServer(serverDeployed string) { // getOrphanedComposeInstanceProjectsRemote returns compose instance projects on a remote server func getOrphanedComposeInstanceProjectsRemote(server config.AvailableServer) ([]string, error) { - output, err := RunCommandOnServer(server, "docker compose ls --format json") + output, err := RunArgsOnServer(server, "docker", "compose", "ls", "--format", "json") if err != nil { return nil, fmt.Errorf("docker compose ls failed on remote: %v", err) } @@ -145,8 +145,7 @@ func getOrphanedComposeInstanceProjectsRemote(server config.AvailableServer) ([] // composeDownProjectRemote removes a docker compose project on a remote server func composeDownProjectRemote(projectName string, server config.AvailableServer) error { - cmd := fmt.Sprintf("docker compose -p %s down --remove-orphans -v", projectName) - output, err := RunCommandOnServer(server, cmd) + output, err := RunArgsOnServer(server, "docker", "compose", "-p", projectName, "down", "--remove-orphans", "--volumes") if err != nil { return fmt.Errorf("docker compose down failed on remote: %v, output: %s", err, output) } diff --git a/pkg/remoteManager/image.go b/pkg/remoteManager/image.go index 77fecac7..3bb845b8 100644 --- a/pkg/remoteManager/image.go +++ b/pkg/remoteManager/image.go @@ -1,27 +1,23 @@ package remoteManager import ( - "fmt" - - "os/exec" + "errors" "github.com/sdslabs/beastv4/core/config" ) // Remove image from remote server. func RemoveImageRemote(imageId string, server config.AvailableServer) error { - command := fmt.Sprintf("docker rmi %s", imageId) - _, err := RunCommandOnServer(server, command) + _, err := RunArgsOnServer(server, "docker", "rmi", imageId) return err } // Check for existence on image on remote server func CheckIfImageExistsOnRemote(imageId string, server config.AvailableServer) (bool, error) { - command := fmt.Sprintf("docker inspect --format='{{.ID}}' %s", imageId) - output, err := RunCommandOnServer(server, command) + output, err := RunArgsOnServer(server, "docker", "inspect", "--format", "{{.ID}}", imageId) if err != nil { - exitError, success := err.(*exec.ExitError) - if success && exitError.ExitCode() == 1 { + var commandError *RemoteCommandError + if errors.As(err, &commandError) && commandError.ExitStatus == 1 { return false, nil } return false, err diff --git a/pkg/remoteManager/init.go b/pkg/remoteManager/init.go index e4c9a43f..cf94bfc6 100644 --- a/pkg/remoteManager/init.go +++ b/pkg/remoteManager/init.go @@ -1,7 +1,6 @@ package remoteManager import ( - "fmt" "path/filepath" "github.com/sdslabs/beastv4/core" @@ -24,7 +23,7 @@ func Init() { } defer client.Close() ServerQueue.Push(server) - _, err = RunCommandOnServer(server, fmt.Sprintf("mkdir -p %s", filepath.Join(core.BEAST_REMOTE_GLOBAL_DIR, core.BEAST_STAGING_DIR))) + _, err = RunArgsOnServer(server, "mkdir", "-p", filepath.Join(core.BEAST_REMOTE_GLOBAL_DIR, core.BEAST_STAGING_DIR)) if err != nil { log.Errorf("failed to run command on server %s: %s", server.Host, err.Error()) } diff --git a/pkg/remoteManager/ssh.go b/pkg/remoteManager/ssh.go index baaf4bc1..08949141 100644 --- a/pkg/remoteManager/ssh.go +++ b/pkg/remoteManager/ssh.go @@ -91,7 +91,7 @@ func PingServer(server config.AvailableServer) error { } // Run the command passed as argument on the remote server -func RunCommandOnServer(server config.AvailableServer, cmd string) (string, error) { +func runCommandOnServer(server config.AvailableServer, cmd string) (string, error) { if !server.Active { return "", fmt.Errorf("server is inactive in config.toml") } @@ -124,7 +124,7 @@ func RunArgsOnServer(server config.AvailableServer, arguments ...string) (string if len(arguments) == 0 { return "", fmt.Errorf("remote command arguments are empty") } - return RunCommandOnServer(server, "exec "+shellJoin(arguments)) + return runCommandOnServer(server, "exec "+shellJoin(arguments)) } func RunArgsInDirOnServer(server config.AvailableServer, directory string, arguments ...string) (string, error) { @@ -132,7 +132,7 @@ func RunArgsInDirOnServer(server config.AvailableServer, directory string, argum return "", fmt.Errorf("remote directory and command arguments are required") } command := "cd -- " + shellQuote(directory) + " && exec " + shellJoin(arguments) - return RunCommandOnServer(server, command) + return runCommandOnServer(server, command) } func RunArgsWithEnvOnServer(server config.AvailableServer, environment map[string]string, arguments ...string) (string, error) { @@ -156,7 +156,7 @@ func RunArgsWithEnvOnServer(server config.AvailableServer, environment map[strin command += " " } command += "exec " + shellJoin(arguments) - return RunCommandOnServer(server, command) + return runCommandOnServer(server, command) } // Creates an SSH client to connect to the remote server. From 4a3749050d66a2517a9da8ab464b64d484876541 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Thu, 16 Jul 2026 20:02:27 +0530 Subject: [PATCH 044/149] fix(build): avoid unsafe GOPATH output fallback Signed-off-by: vibhatsu --- scripts/build/build.sh | 45 ++++++++++++++++-------------------------- 1 file changed, 17 insertions(+), 28 deletions(-) diff --git a/scripts/build/build.sh b/scripts/build/build.sh index 0bfb6a8a..0dc36edc 100755 --- a/scripts/build/build.sh +++ b/scripts/build/build.sh @@ -1,25 +1,20 @@ -#!/bin/bash +#!/usr/bin/env bash # Build Beast -# Exit if any steps fail -set -e - -CWD=${PWD} +set -euo pipefail GO_FLAGS=${GO_FLAGS:-"-tags netgo"} GO_CMD=${GO_CMD:-"build"} -BUILD_USER=${BUILD_USER:-"${USER}@${HOSTNAME}"} -BUILD_DATE=${BUILD_DATE:-$( date +%Y%m%d-%H:%M:%S )} +BUILD_USER=${BUILD_USER:-"${USER:-unknown}@${HOSTNAME:-unknown}"} +BUILD_DATE=${BUILD_DATE:-$(date -u +%Y%m%d-%H:%M:%S)} VERBOSE=${VERBOSE:-} +OUTPUT=${BEAST_OUTPUT:-"$(go env GOPATH)/bin/beast"} repo_path="github.com/sdslabs/beastv4" main_package="github.com/sdslabs/beastv4/cmd/beast" -mysql_agent="github.com/sdslabs/beastv4/cmd/agents/mysql" -mongo_agent="github.com/sdslabs/beastv4/cmd/agents/mongo" - # Get branch revision and version -version="0.1" +version="0.2" revision=$(git rev-parse --short HEAD 2> /dev/null || echo 'unknown') branch=$(git rev-parse --abbrev-ref HEAD 2> /dev/null || echo 'unknown') @@ -27,21 +22,13 @@ branch=$(git rev-parse --abbrev-ref HEAD 2> /dev/null || echo 'unknown') go_version=$(go version | sed -e 's/^[^0-9.]*\([0-9.]*\).*/\1/') -# go 1.4 requires ldflags format to be "-X key value", not "-X key=value" -# ldseparator here is for cross compatibility between go versions - -ldseparator="=" -if [ "${go_version:0:3}" = "1.4" ]; then - ldseparator=" " -fi - ldflags=" - -X ${repo_path}/version.Version${ldseparator}${version} - -X ${repo_path}/version.Revision${ldseparator}${revision} - -X ${repo_path}/version.Branch${ldseparator}${branch} - -X ${repo_path}/version.BuildUser${ldseparator}${BUILD_USER} - -X ${repo_path}/version.BuildDate${ldseparator}${BUILD_DATE} - -X ${repo_path}/version.GoVersion${ldseparator}${go_version}" + -X ${repo_path}/version.Version=${version} + -X ${repo_path}/version.Revision=${revision} + -X ${repo_path}/version.Branch=${branch} + -X ${repo_path}/version.BuildUser=${BUILD_USER} + -X ${repo_path}/version.BuildDate=${BUILD_DATE} + -X ${repo_path}/version.GoVersion=${go_version}" echo ">>> Building Beast..." @@ -49,7 +36,9 @@ if [ -n "$VERBOSE" ]; then echo "Building with -ldflags $ldflags" fi -GOBIN=$PWD go "${GO_CMD}" -o "${GOPATH}/bin/beast" ${GO_FLAGS} -ldflags "${ldflags}" "${main_package}" +mkdir -p "$(dirname "${OUTPUT}")" +# GO_FLAGS is intentionally word-split to preserve the existing override interface. +# shellcheck disable=SC2086 +go "${GO_CMD}" -o "${OUTPUT}" ${GO_FLAGS} -ldflags "${ldflags}" "${main_package}" -echo "[*] Build Complete." -exit 0 +echo "[*] Build complete: ${OUTPUT}" From 5e3172d05dac6fa5430ecb65898de9e366fd4296 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Thu, 16 Jul 2026 20:07:43 +0530 Subject: [PATCH 045/149] fix(setup): remove privileged unpinned installers Signed-off-by: vibhatsu --- scripts/installenv.sh | 107 +++++++++++++++--------------------------- 1 file changed, 37 insertions(+), 70 deletions(-) diff --git a/scripts/installenv.sh b/scripts/installenv.sh index 298aeffc..b919461f 100755 --- a/scripts/installenv.sh +++ b/scripts/installenv.sh @@ -1,70 +1,37 @@ -#!/bin/bash - -set -euxo pipefail - -GO_VERSION='1.13.1' - -function update() { - sudo apt-get update -} - -function upgrade() { - sudo apt-get -y upgrade -} - -echo 'Installing dependecies for beast.' - -function install_docker() { - if ! [ -x "$(command -v docker)" ]; then - echo 'Info: docker is not installed, Installing...' - - curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add - - sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" - - update - - sudo apt-cache policy docker-ce - - sudo apt-get install -y docker-ce - - echo 'Docker installed.' - fi - - sudo usermod -aG docker ${USER} -} - -function install_go() { - if ! [ -x "$(command -v go)" ]; then - echo 'Info: golang is not installed, Installing...' - - wget -O "/tmp/go${GO_VERSION}.linux-amd64.tar.gz" "https://dl.google.com/go/go${GO_VERSION}.linux-amd64.tar.gz" - sudo tar -xvf "/tmp/go${GO_VERSION}.linux-amd64.tar.gz" -C /usr/local/ - - rm -rf "/tmp/go${GO_VERSION}.linux-amd64.tar.gz" - - echo 'export GOROOT=/usr/local/go' | tee -a $HOME/.bashrc - echo 'export PATH=$PATH:/usr/local/go/bin:$HOME/go/bin/' | tee -a $HOME/.bashrc - echo 'export GOPATH=$HOME/go' | tee -a $HOME/.bashrc - - source ~/.bashrc - - echo 'Golang installed.' - fi - - /usr/local/go/bin/go version -} - -function install_air(){ - curl -sSfL https://raw.githubusercontent.com/cosmtrek/air/master/install.sh | sh -s -- -b $(go env GOPATH)/bin -} - -update -upgrade - -sudo apt-get install -y apt-transport-https libsqlite3-dev build-essential gcc g++ - -install_docker -install_go -install_air - -exit 0 +#!/usr/bin/env bash + +set -euo pipefail + +install_dev_tools=false +case "${1:-}" in + "") ;; + --dev) install_dev_tools=true ;; + *) echo "usage: $0 [--dev]" >&2; exit 2 ;; +esac + +required_commands=(go docker git make) +missing=() +for command in "${required_commands[@]}"; do + if ! command -v "${command}" >/dev/null 2>&1; then + missing+=("${command}") + fi +done +if ((${#missing[@]} > 0)); then + echo "missing required commands: ${missing[*]}" >&2 + echo "install them with your operating system's trusted package manager" >&2 + exit 1 +fi + +go version +docker version --format '{{.Client.Version}}' >/dev/null +if ! docker info >/dev/null 2>&1; then + echo "Docker is installed but the daemon is unavailable to the current user" >&2 + exit 1 +fi + +if [[ "${install_dev_tools}" == true ]]; then + echo "installing pinned Air development tool" + go install github.com/air-verse/air@v1.61.7 +fi + +echo "Beast prerequisites are available" From 724ea378ad1e6ab80d36f91b008828f1b4f04bce Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Thu, 16 Jul 2026 20:10:13 +0530 Subject: [PATCH 046/149] fix(setup): create user-scoped fail-fast environment Signed-off-by: vibhatsu --- scripts/provision/setup.sh | 53 ++----------------- setup.sh | 102 ++++++++++++++----------------------- 2 files changed, 42 insertions(+), 113 deletions(-) mode change 100644 => 100755 scripts/provision/setup.sh diff --git a/scripts/provision/setup.sh b/scripts/provision/setup.sh old mode 100644 new mode 100755 index 907dd6e0..1d3ac32c --- a/scripts/provision/setup.sh +++ b/scripts/provision/setup.sh @@ -1,51 +1,6 @@ -#!/bin/bash +#!/usr/bin/env bash -echo -e "Setting up sample environment for beast..." - -# Creating required directories -mkdir -p "$HOME/.beast" "$HOME/.beast/assets/logo" "$HOME/.beast/remote" "$HOMER/.beast/uploads" "$HOME/.beast/secrets" "$HOME/.beast/scripts" "$HOME/.beast/staging" - -# Creating random authorized_keys and secret.key files -echo -e "auth_keys" > $HOME/.beast/authorized_keys -echo -e "auth_keys" > $HOME/.beast/secret.key - -# Set beast folder location in vagant box -BEAST_FOLDER=$HOME/beast - -BEAST_GLOBAL_CONFIG=$HOME/.beast/config.toml -EXAMPLE_CONFIG_FILE=$BEAST_FOLDER/_examples/example.config.toml - -if [ -f "$BEAST_GLOBAL_CONFIG" ]; then - echo -e "Found $BEAST_GLOBAL_CONFIG" -else - if [ -f "$EXAMPLE_CONFIG_FILE" ]; then - echo -e "Copying example config file" - cp $BEAST_FOLDER/_examples/example.config.toml $BEAST_GLOBAL_CONFIG - else - echo -e '\e[93mCould not find example.config.toml' - echo -e 'Downloading example.config.toml' - wget https://raw.githubusercontent.com/sdslabs/beast/master/_examples/example.config.toml - cp ./example.config.toml $BEAST_GLOBAL_CONFIG - exit - fi - sed -i "s/vsts/$USER/g" $BEAST_GLOBAL_CONFIG -fi - -echo -e "Created .beast folder..." - -echo -e "Building beast..." - -export GO111MODULES=on - -echo -e 'checking if docker is running...' -# Checking if docker deamon is running or not by checking its PID -DOCKER_PID_FILE=/var/run/docker.pid -if [ -f "$DOCKER_PID_FILE" ]; then - echo -e "Docker is running." -else - echo -e '\e[31mDocker daemon is not running' - echo -e '\e[31mAborting...' - echo -e "\e[31mPlease start docker daemon and restart again" - exit -fi +set -euo pipefail +repo_dir=${BEAST_REPOSITORY:-"${HOME}/beast"} +exec "${repo_dir}/setup.sh" diff --git a/setup.sh b/setup.sh index ae8d4bab..cd881807 100755 --- a/setup.sh +++ b/setup.sh @@ -1,69 +1,43 @@ -#!/bin/bash - -echo -e "Setting up sample environment for beast..." - -# Creating required directories -mkdir -p "/home/$USER/.beast" "/home/$USER/.beast/assets/logo" "/home/$USER/.beast/assets/mailTemplates" "/home/$USER/.beast/remote" "/home/$USER/.beast/uploads" "/home/$USER/.beast/secrets" "/home/$USER/.beast/scripts" "/home/$USER/.beast/staging" - -# Creating random authorized_keys and secret.key files -echo -e "auth_keys" >/home/$USER/.beast/authorized_keys -echo -e "auth_keys" >/home/$USER/.beast/secret.key - -BEAST_GLOBAL_CONFIG=~/.beast/config.toml -EXAMPLE_CONFIG_FILE=./_examples/example.config.toml - -if [ -f "$BEAST_GLOBAL_CONFIG" ]; then - echo -e "Found $BEAST_GLOBAL_CONFIG" -else - if [ -f "$EXAMPLE_CONFIG_FILE" ]; then - echo -e "Copying example config file" - cp ./_examples/example.config.toml $BEAST_GLOBAL_CONFIG - else - echo -e '\e[93mCould not find example.config.toml' - echo -e 'Downloading example.config.toml' - wget https://raw.githubusercontent.com/sdslabs/beast/master/_examples/example.config.toml - cp ./example.config.toml $BEAST_GLOBAL_CONFIG - exit - fi - sed -i "s/vsts/$USER/g" $BEAST_GLOBAL_CONFIG -fi - -echo -e "Created .beast folder..." - -echo -e "Building beast..." - -export GO111MODULES=on - -echo -e 'validating $GOPATH...' -if [ -z "$GOPATH" ]; then - echo -e '\e[31m$GOPATH is not set...' - echo -e '\e[31mAborting...' - exit -fi - -echo -e 'checking if docker is running...' -# Checking if docker deamon is running or not by checking its PID -DOCKER_PID_FILE=/var/run/docker.pid -if [ -f "$DOCKER_PID_FILE" ]; then - echo -e "Docker is running." -else - echo -e '\e[31mDocker daemon is not running' - echo -e '\e[31mAborting...' - echo -e "\e[31mPlease start docker daemon and restart again" - exit +#!/usr/bin/env bash + +set -euo pipefail +umask 077 + +repo_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +beast_dir=${BEAST_HOME:-"${HOME}/.beast"} +config_file="${beast_dir}/config.toml" + +"${repo_dir}/scripts/installenv.sh" + +mkdir -p \ + "${beast_dir}/assets/logo" \ + "${beast_dir}/assets/mailTemplates" \ + "${beast_dir}/backup/cache" \ + "${beast_dir}/backup/db" \ + "${beast_dir}/cache" \ + "${beast_dir}/remote" \ + "${beast_dir}/secrets" \ + "${beast_dir}/staging" \ + "${beast_dir}/uploads" + +created_config=false +if [[ ! -e "${config_file}" ]]; then + cp "${repo_dir}/_examples/example.config.toml" "${config_file}" + chmod 0600 "${config_file}" + jwt_secret=$(od -An -N32 -tx1 /dev/urandom | tr -d ' \n') + sed -i "s/CHANGE_ME_GENERATED_BY_SETUP/${jwt_secret}/" "${config_file}" + created_config=true +elif [[ -L "${config_file}" || $(stat -c '%a' "${config_file}") != 600 ]]; then + echo "existing config must be a regular file with mode 0600: ${config_file}" >&2 + exit 1 fi -echo -e "Installing air for live reloading" -curl -sSfL https://raw.githubusercontent.com/cosmtrek/air/master/install.sh | sh -s -- -b $(go env GOPATH)/bin +BEAST_OUTPUT=${BEAST_OUTPUT:-"$(go env GOPATH)/bin/beast"} make -C "${repo_dir}" build -echo -e "Building beast..." -make build -if [ $? -eq 0 ]; then - echo -e "\e[92mPlease run beast server by following command:-" - echo -e "******************" - echo -e "* \e[5mbeast run -v \e[25m*" - echo -e "******************" +if [[ "${created_config}" == true ]]; then + echo "Created ${config_file} with a unique JWT secret." + echo "Before starting Beast, configure PostgreSQL, Redis, SMTP, and TLS certificate paths." else - echo -e "\e[31mBeast build failed. Please check above errors" - exit 1 + echo "Using existing ${config_file}." fi +echo "Beast binary: ${BEAST_OUTPUT:-$(go env GOPATH)/bin/beast}" From fc387451d10725ba45c1945d0f3641e212b3323a Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Thu, 16 Jul 2026 20:14:41 +0530 Subject: [PATCH 047/149] feat(setup): add safe controller teardown Signed-off-by: vibhatsu --- scripts/teardown.sh | 46 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100755 scripts/teardown.sh diff --git a/scripts/teardown.sh b/scripts/teardown.sh new file mode 100755 index 00000000..8dc3d32a --- /dev/null +++ b/scripts/teardown.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash + +set -euo pipefail + +purge_data=false +case "${1:-}" in + "") ;; + --purge-data) purge_data=true ;; + *) echo "usage: $0 [--purge-data]" >&2; exit 2 ;; +esac + +beast_dir=${BEAST_HOME:-"${HOME}/.beast"} +lock_file="${beast_dir}/controller.lock" + +if [[ -f "${lock_file}" ]]; then + read -r controller_pid <"${lock_file}" || true + if [[ "${controller_pid:-}" =~ ^[0-9]+$ ]] && kill -0 "${controller_pid}" 2>/dev/null; then + executable=$(readlink -f "/proc/${controller_pid}/exe" 2>/dev/null || true) + if [[ $(basename -- "${executable}") != beast ]]; then + echo "refusing to signal PID ${controller_pid}: it is not a Beast process" >&2 + exit 1 + fi + kill -TERM "${controller_pid}" + for _ in {1..60}; do + if ! kill -0 "${controller_pid}" 2>/dev/null; then + break + fi + sleep 1 + done + if kill -0 "${controller_pid}" 2>/dev/null; then + echo "Beast did not stop within 60 seconds; data was not removed" >&2 + exit 1 + fi + fi +fi + +if [[ "${purge_data}" == true ]]; then + if [[ -z "${beast_dir}" || "${beast_dir}" == / || "${beast_dir}" == "${HOME}" ]]; then + echo "refusing unsafe Beast data path: ${beast_dir}" >&2 + exit 1 + fi + rm -rf -- "${beast_dir}" + echo "Removed Beast local data at ${beast_dir}. External PostgreSQL and Redis data were not removed." +else + echo "Beast is stopped. Use --purge-data to remove ${beast_dir}." +fi From bb6ce621e5e06eeb4b88e2afa786699385568d26 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Thu, 16 Jul 2026 20:19:37 +0530 Subject: [PATCH 048/149] fix(images): use existing Ubuntu LTS tag Signed-off-by: vibhatsu --- core/constants.go | 2 +- scripts/test/backend_submit_race.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/core/constants.go b/core/constants.go index 297b5576..27b5539f 100644 --- a/core/constants.go +++ b/core/constants.go @@ -79,7 +79,7 @@ const ( // chall actions const ( // chall env MAX_PORT_PER_CHALL uint32 = 3 BEAST_CHALLENGES_STATIC_PORT uint32 = 80 - DEFAULT_BASE_IMAGE string = "ubuntu:24.03" + DEFAULT_BASE_IMAGE string = "ubuntu:24.04" DEFAULT_XINETD_CONF_FILE string = "xinetd.conf" BEAST_STATIC_AUTH_FILE string = ".static.beast.htpasswd" ALLOWED_MIN_PORT_VALUE uint32 = 10000 diff --git a/scripts/test/backend_submit_race.sh b/scripts/test/backend_submit_race.sh index b17a1b0b..abcb7eb8 100755 --- a/scripts/test/backend_submit_race.sh +++ b/scripts/test/backend_submit_race.sh @@ -198,7 +198,7 @@ psql_root -v ON_ERROR_STOP=1 -c "CREATE DATABASE $PGDATABASE" >/dev/null cat >"$TEST_HOME/.beast/config.toml" < Date: Thu, 16 Jul 2026 20:21:40 +0530 Subject: [PATCH 049/149] feat(config): expand explicit home path prefixes Signed-off-by: vibhatsu --- utils/file.go | 19 +++++++++++++++++++ utils/file_test.go | 17 +++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/utils/file.go b/utils/file.go index a42241ae..c568787e 100644 --- a/utils/file.go +++ b/utils/file.go @@ -86,6 +86,25 @@ func ValidateSecretFile(filePath string) error { return nil } +func ExpandHomePath(filePath string) (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home directory: %w", err) + } + switch { + case filePath == "~" || filePath == "$HOME" || filePath == "${HOME}": + return home, nil + case strings.HasPrefix(filePath, "~/"): + return filepath.Join(home, strings.TrimPrefix(filePath, "~/")), nil + case strings.HasPrefix(filePath, "$HOME/"): + return filepath.Join(home, strings.TrimPrefix(filePath, "$HOME/")), nil + case strings.HasPrefix(filePath, "${HOME}/"): + return filepath.Join(home, strings.TrimPrefix(filePath, "${HOME}/")), nil + default: + return filePath, nil + } +} + // ResolvePathWithin resolves an existing relative path and verifies that it // remains inside root, including after following symbolic links. func ResolvePathWithin(root, relativePath string) (string, error) { diff --git a/utils/file_test.go b/utils/file_test.go index fc40ed88..7be400ff 100644 --- a/utils/file_test.go +++ b/utils/file_test.go @@ -83,3 +83,20 @@ func TestValidateSecretFileRequiresPrivateRegularFile(t *testing.T) { t.Fatal("expected symlink error") } } + +func TestExpandHomePathOnlyExpandsHomePrefix(t *testing.T) { + t.Setenv("HOME", "/tmp/beast-home") + for _, input := range []string{"~/secret", "$HOME/secret", "${HOME}/secret"} { + got, err := ExpandHomePath(input) + if err != nil { + t.Fatal(err) + } + if got != "/tmp/beast-home/secret" { + t.Fatalf("ExpandHomePath(%q) = %q", input, got) + } + } + got, err := ExpandHomePath("$UNTRUSTED/secret") + if err != nil || got != "$UNTRUSTED/secret" { + t.Fatalf("unexpected arbitrary expansion: %q, %v", got, err) + } +} From 8b96626b1bfbba4284145c47f9982fe523ba7c24 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Thu, 16 Jul 2026 20:58:49 +0530 Subject: [PATCH 050/149] fix(config): resolve configured secret paths Signed-off-by: vibhatsu --- core/config/config.go | 22 ++++++++++++++++++++++ core/config/utils_test.go | 12 ++++++++++++ 2 files changed, 34 insertions(+) diff --git a/core/config/config.go b/core/config/config.go index f368ee0b..b2df72d2 100644 --- a/core/config/config.go +++ b/core/config/config.go @@ -141,6 +141,15 @@ func (config *ServerConfig) Validate() error { if config.TLSCertFile == "" || config.TLSKeyFile == "" { return errors.New("server tls_cert_file and tls_key_file are required") } + var err error + config.TLSCertFile, err = utils.ExpandHomePath(config.TLSCertFile) + if err != nil { + return err + } + config.TLSKeyFile, err = utils.ExpandHomePath(config.TLSKeyFile) + if err != nil { + return err + } if err := utils.ValidateFileExists(config.TLSCertFile); err != nil { return fmt.Errorf("invalid TLS certificate file: %w", err) } @@ -349,6 +358,14 @@ func (config *AvailableServer) ValidateServerConfig() error { if config.KnownHostsFile == "" { config.KnownHostsFile = filepath.Join(os.Getenv("HOME"), ".ssh", "known_hosts") } + config.SSHKeyPath, err = utils.ExpandHomePath(config.SSHKeyPath) + if err != nil { + return err + } + config.KnownHostsFile, err = utils.ExpandHomePath(config.KnownHostsFile) + if err != nil { + return err + } err = utils.ValidateSecretFile(config.SSHKeyPath) if err != nil { @@ -374,6 +391,11 @@ func (config *GitRemote) ValidateGitConfig() error { log.Error("One of url, RemoteName or ssh_key is missing in the config") return errors.New("git remote config not valid, config parameters missing") } + var err error + config.Secret, err = utils.ExpandHomePath(config.Secret) + if err != nil { + return err + } gitUrlRegexp, err := regexp.Compile(config.Url) if err != nil { diff --git a/core/config/utils_test.go b/core/config/utils_test.go index 5f9addea..cc189ec1 100644 --- a/core/config/utils_test.go +++ b/core/config/utils_test.go @@ -153,3 +153,15 @@ func TestServerConfigRequiresTLSFiles(t *testing.T) { t.Fatalf("expected required TLS files error, got %v", err) } } + +func TestServerConfigExpandsHomePaths(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + server := ServerConfig{TLSCertFile: "$HOME/cert.pem", TLSKeyFile: "${HOME}/key.pem"} + if err := server.Validate(); err == nil { + t.Fatal("expected missing certificate error") + } + if server.TLSCertFile != filepath.Join(home, "cert.pem") || server.TLSKeyFile != filepath.Join(home, "key.pem") { + t.Fatalf("paths were not expanded: %+v", server) + } +} From f42c31e43da68759ea1ea76a0631a908255d12e7 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Thu, 16 Jul 2026 21:05:28 +0530 Subject: [PATCH 051/149] fix(config): provide secure v0.2 example Signed-off-by: vibhatsu --- _examples/example.config.toml | 39 +++++++++++++++-------------------- core/config/utils_test.go | 8 +++++++ 2 files changed, 25 insertions(+), 22 deletions(-) diff --git a/_examples/example.config.toml b/_examples/example.config.toml index 8c916360..14c49425 100644 --- a/_examples/example.config.toml +++ b/_examples/example.config.toml @@ -1,19 +1,9 @@ -# Authorized key file used by ssh daemon running on the host -# This is used for forwarding ssh connection to docker containers, the -# access to a container is only given to the author of the challenge. -authorized_keys_file = "$HOME/.beast/beast_authorized_keys" - -# Directory which will contain all the autogenerated scripts by beast -# These scripts are the heart to above authorized keys file. Each entry in authorized -# keys file as a corresponding script which is executed during an SSH attempt. -scripts_dir = "$HOME/.beast/scripts" - # Base OS image that beast allows the challenges to use. -allowed_base_images = ["ubuntu:18.04", "ubuntu:16.04", "debian:jessie"] +allowed_base_images = ["ubuntu:24.04", "ubuntu:18.04", "debian:bookworm"] # For authentication purposes beast uses JWT based authentication, this is the # key used for encrypting the claims of a user. Keep this strong. -jwt_secret = "beast_jwt_secret_SUPER_STRONG_0x100010000100" +jwt_secret = "CHANGE_ME_GENERATED_BY_SETUP" #Health Prober, if active, starts a health prober on a thread which checks for #deployed challenges, containers and servers after every ticker_frequency period @@ -24,10 +14,10 @@ health_prober = false ticker_frequency = 3000 -# Container default resource limits for each challenge, this can be -# Overridden by challenge configuration beast.toml file. -default_cpu_shares = 1024 -default_memory_limit = 1024 +# Container defaults and hard ceilings for challenge overrides. +default_cpu_shares = 512 +default_cpus_limit = 0.25 +default_memory_limit = 536870912 default_pids_limit = 100 # List of ip addresses of all the servers where challenge could be deployed for @@ -43,6 +33,7 @@ username = "user1" # Path to private SSH key for interacting with the server. ssh_key_path = "/path/to/your/private/key1" +known_hosts_file = "$HOME/.ssh/known_hosts" # Port range for this server (format: START:END) port_range = "30000:40000" @@ -62,7 +53,7 @@ username = "" ssh_key_path = "" # Port range for this server (format: START:END) - uses local_host_port_range if empty -port_range = "" +port_range = "10000:20000" # Status of remote server to be used active = true @@ -104,16 +95,16 @@ ssh_key = "$HOME/.beast/secrets/key.priv" active = false [mail_config] -from = "your-email@example.com" -password = "your-email-password" -smtpHost = "smtp.example.com" -smtpPort = "587" +from = "" +password = "" +smtpHost = "" +smtpPort = "" # Configuration to connect to Postgresql database [psql_config] user = "beast" -password = "12345678" +password = "CHANGE_ME" dbname = "beast" host = "localhost" port = "5432" @@ -130,6 +121,10 @@ default_expiration = 300 max_extension = 600 max_instances_per_user = 3 +[server] +tls_cert_file = "$HOME/.beast/secrets/tls.crt" +tls_key_file = "$HOME/.beast/secrets/tls.key" + # 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/core/config/utils_test.go b/core/config/utils_test.go index cc189ec1..6f38f70a 100644 --- a/core/config/utils_test.go +++ b/core/config/utils_test.go @@ -165,3 +165,11 @@ func TestServerConfigExpandsHomePaths(t *testing.T) { t.Fatalf("paths were not expanded: %+v", server) } } + +func TestExampleGlobalConfigHasNoUnknownKeys(t *testing.T) { + var config BeastConfig + path := filepath.Join("..", "..", "_examples", "example.config.toml") + if err := decodeTOMLFileStrict(path, &config); err != nil { + t.Fatal(err) + } +} From 3b5ab3f626e586f0fa967145e44ac3f7b67b54c7 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Thu, 16 Jul 2026 21:11:44 +0530 Subject: [PATCH 052/149] fix(examples): conform challenge configs to v0.2 Signed-off-by: vibhatsu --- _examples/bare-docker/beast.toml | 3 +-- _examples/compose-type/beast.toml | 1 - _examples/instanced-compose/beast.toml | 3 +-- _examples/instanced-service/beast.toml | 3 +-- _examples/service/beast.toml | 3 +-- _examples/simple/beast.toml | 3 +-- _examples/static-chall/beast.toml | 10 ++++------ _examples/web-php-mysql/beast.toml | 3 +-- _examples/web-php/beast.toml | 3 +-- _examples/xinetd-service/beast.toml | 8 +++----- core/config/utils_test.go | 17 +++++++++++++++++ 11 files changed, 31 insertions(+), 26 deletions(-) diff --git a/_examples/bare-docker/beast.toml b/_examples/bare-docker/beast.toml index 10f4f65d..f7d1fd39 100644 --- a/_examples/bare-docker/beast.toml +++ b/_examples/bare-docker/beast.toml @@ -1,7 +1,6 @@ [author] name = "fristonio" email = "contact+fristonio@sdslabs.co.in" -ssh_key = "ssh-rsa AAAAB3NzaC1y" [challenge.metadata] name = "docker-type" @@ -23,7 +22,7 @@ ports = [10005] default_port = 10005 [resource] -cpu_shares = 1024 +cpu_shares = 512 memory_limit = 536870912 pids_limit = 100 cpuslimit = 0.25 diff --git a/_examples/compose-type/beast.toml b/_examples/compose-type/beast.toml index 31698fa3..04113b03 100644 --- a/_examples/compose-type/beast.toml +++ b/_examples/compose-type/beast.toml @@ -1,7 +1,6 @@ [author] name = "example" email = "example@local" -ssh_key = "ssh-rsa AAAAB3NzaC1y" [challenge.metadata] name = "compose-type" diff --git a/_examples/instanced-compose/beast.toml b/_examples/instanced-compose/beast.toml index b07b2df7..21f31667 100644 --- a/_examples/instanced-compose/beast.toml +++ b/_examples/instanced-compose/beast.toml @@ -1,7 +1,6 @@ [author] name = "beast-admin" email = "admin@beast.local" -ssh_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ" [challenge.metadata] name = "instanced-compose" @@ -28,7 +27,7 @@ docker_compose = "docker-compose.yml" default_port_var = "INSTANCE_PORT" [resource] -cpu_shares = 1024 +cpu_shares = 512 memory_limit = 536870912 pids_limit = 100 cpuslimit = 0.25 diff --git a/_examples/instanced-service/beast.toml b/_examples/instanced-service/beast.toml index 695a8870..cd08f5d9 100644 --- a/_examples/instanced-service/beast.toml +++ b/_examples/instanced-service/beast.toml @@ -1,7 +1,6 @@ [author] name = "beast-admin" email = "admin@beast.local" -ssh_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ" [challenge.metadata] name = "instanced-service" @@ -32,7 +31,7 @@ service_path = "pwn" base_image = "ubuntu:18.04" [resource] -cpu_shares = 1024 +cpu_shares = 512 memory_limit = 536870912 pids_limit = 100 cpuslimit = 0.25 diff --git a/_examples/service/beast.toml b/_examples/service/beast.toml index f0a3e554..5eb44884 100644 --- a/_examples/service/beast.toml +++ b/_examples/service/beast.toml @@ -1,7 +1,6 @@ [author] name = "fristonio" email = "contact+fristonio@sdslabs.co.in" -ssh_key = "ssh-rsa AAAAB3NzaC1y" [challenge.metadata] name = "service" @@ -27,7 +26,7 @@ ports = [10004] default_port = 10004 [resource] -cpu_shares = 1024 +cpu_shares = 512 memory_limit = 536870912 pids_limit = 100 cpuslimit = 0.25 diff --git a/_examples/simple/beast.toml b/_examples/simple/beast.toml index 1a408966..6a0fd258 100644 --- a/_examples/simple/beast.toml +++ b/_examples/simple/beast.toml @@ -1,7 +1,6 @@ [author] name = "hi" email = "hi@gmail.com" -ssh_key = "hi" [challenge.metadata] name = "simple" @@ -25,7 +24,7 @@ ports = [10005] default_port = 10005 [resource] -cpu_shares = 1024 +cpu_shares = 512 memory_limit = 536870912 pids_limit = 100 cpuslimit = 0.25 diff --git a/_examples/static-chall/beast.toml b/_examples/static-chall/beast.toml index 7d9c5560..dee0272e 100644 --- a/_examples/static-chall/beast.toml +++ b/_examples/static-chall/beast.toml @@ -1,13 +1,15 @@ [author] name = "fristonio" email = "contact+fristonio@sdslabs.co.in" -ssh_key = "ssh-rsa AAAAB3NzaC1y" [challenge.metadata] name = "static-chall" flag = "BACKDOOR{SAMPLE_FLAG}" type = "static" points=150 +maxPoints = 150 +minPoints = 50 +tags = ["easy", "web"] [[challenge.metadata.hints]] text = "simple_hint_1" @@ -17,15 +19,11 @@ points = 10 text = "simple_hint_2" points = 20 -maxPoints = 100 -minPoints = 50 -tags = ["easy", "web"] - [challenge.env] static_dir = "static" [resource] -cpu_shares = 1024 +cpu_shares = 512 memory_limit = 536870912 pids_limit = 100 cpuslimit = 0.25 diff --git a/_examples/web-php-mysql/beast.toml b/_examples/web-php-mysql/beast.toml index 5e45eb8c..c3e19c22 100644 --- a/_examples/web-php-mysql/beast.toml +++ b/_examples/web-php-mysql/beast.toml @@ -1,7 +1,6 @@ [author] name = "fristonio" email = "contact+fristonio@sdslabs.co.in" -ssh_key = "ssh-rsa AAAAB3NzaC1y" [challenge.metadata] name = "web-php-mysql" @@ -27,7 +26,7 @@ web_root = "challenge" default_port = 10004 [resource] -cpu_shares = 1024 +cpu_shares = 512 memory_limit = 536870912 pids_limit = 100 cpuslimit = 0.25 diff --git a/_examples/web-php/beast.toml b/_examples/web-php/beast.toml index eb68611e..32c8cc19 100644 --- a/_examples/web-php/beast.toml +++ b/_examples/web-php/beast.toml @@ -1,7 +1,6 @@ [author] name = "fristonio" email = "contact+fristonio@sdslabs.co.in" -ssh_key = "ssh-rsa AAAAB3NzaC1y" [challenge.metadata] name = "web-php" @@ -25,7 +24,7 @@ web_root = "challenge" default_port = 10002 [resource] -cpu_shares = 1024 +cpu_shares = 512 memory_limit = 536870912 pids_limit = 100 cpuslimit = 0.25 diff --git a/_examples/xinetd-service/beast.toml b/_examples/xinetd-service/beast.toml index 3ebbbcfc..ab182a04 100644 --- a/_examples/xinetd-service/beast.toml +++ b/_examples/xinetd-service/beast.toml @@ -1,7 +1,6 @@ [author] name = "contact" email = "contact@sdslabs.co.in" -ssh_key = "ssh-rsa AAAAB3NzaC1y" [challenge.metadata] name = "xinetd-service" @@ -9,6 +8,7 @@ flag = "CTF{not_the_flag}" type = "service" points = 500 maxAttemptLimit = 10 +preReqs = ["simple", "web-php"] [[challenge.metadata.hints]] text = "simple_hint_1" @@ -18,19 +18,17 @@ points = 10 text = "simple_hint_2" points = 20 -preReqs = ["simple", "web-php"] - [challenge.env] apt_deps = ["gcc", "socat"] setup_scripts = ["setup.sh"] -xinetd_config = "ctf.xinetd" +xinetd_conf = "ctf.xinetd" service_path = "pwn" ports = [10003] default_port = 10003 [resource] -cpu_shares = 1024 +cpu_shares = 512 memory_limit = 536870912 pids_limit = 100 cpuslimit = 0.25 diff --git a/core/config/utils_test.go b/core/config/utils_test.go index 6f38f70a..ab91d748 100644 --- a/core/config/utils_test.go +++ b/core/config/utils_test.go @@ -173,3 +173,20 @@ func TestExampleGlobalConfigHasNoUnknownKeys(t *testing.T) { t.Fatal(err) } } + +func TestExampleChallengeConfigsHaveNoUnknownKeys(t *testing.T) { + paths, err := filepath.Glob(filepath.Join("..", "..", "_examples", "*", "beast.toml")) + if err != nil { + t.Fatal(err) + } + if len(paths) == 0 { + t.Fatal("no example challenge configs found") + } + for _, path := range paths { + t.Run(filepath.Base(filepath.Dir(path)), func(t *testing.T) { + if _, err := LoadChallengeConfig(path); err != nil { + t.Fatal(err) + } + }) + } +} From 3190853b0f7e1e937eb0aab40d1a01b0a7cb4563 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Thu, 16 Jul 2026 21:18:10 +0530 Subject: [PATCH 053/149] fix(config): stabilize available challenge types Signed-off-by: vibhatsu --- core/config/utils.go | 12 ++++++++++-- core/config/utils_test.go | 15 +++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/core/config/utils.go b/core/config/utils.go index 80dcfab5..1cd886e5 100644 --- a/core/config/utils.go +++ b/core/config/utils.go @@ -37,7 +37,11 @@ func LoadChallengeConfig(path string) (BeastChallengeConfig, error) { } func GetAvailableChallengeTypes() []string { - types := core.AVAILABLE_CHALLENGE_TYPES + types := append([]string(nil), core.AVAILABLE_CHALLENGE_TYPES...) + seen := make(map[string]bool, len(types)) + for _, challengeType := range types { + seen[challengeType] = true + } // Extract all the web challenges type. for k := range core.DockerBaseImageForWebChall { @@ -45,10 +49,14 @@ func GetAvailableChallengeTypes() []string { for k2 := range core.DockerBaseImageForWebChall[k][k1] { newType := "web:" + k + ":" + k1 + ":" + k2 newType = strings.TrimRight(strings.Replace(newType, "default", "", -1), ":") - types = append(types, newType) + if !seen[newType] { + types = append(types, newType) + seen[newType] = true + } } } } + sort.Strings(types) return types } diff --git a/core/config/utils_test.go b/core/config/utils_test.go index ab91d748..a44f8d85 100644 --- a/core/config/utils_test.go +++ b/core/config/utils_test.go @@ -190,3 +190,18 @@ func TestExampleChallengeConfigsHaveNoUnknownKeys(t *testing.T) { }) } } + +func TestGetAvailableChallengeTypesIsStable(t *testing.T) { + first := GetAvailableChallengeTypes() + second := GetAvailableChallengeTypes() + if len(first) != len(second) { + t.Fatalf("challenge types grew between calls: %d then %d", len(first), len(second)) + } + seen := make(map[string]bool, len(first)) + for _, challengeType := range first { + if seen[challengeType] { + t.Fatalf("duplicate challenge type %q", challengeType) + } + seen[challengeType] = true + } +} From 6a3d1d675cae87e8ab7b1b270ae2f13d9a8189be Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Thu, 16 Jul 2026 21:30:58 +0530 Subject: [PATCH 054/149] fix(challenges): validate untrusted metadata and ports Signed-off-by: vibhatsu --- core/config/challenge.go | 79 +++++++++++++++++++++++++++++++++++++-- core/config/utils_test.go | 67 +++++++++++++++++++++++++++++++++ 2 files changed, 142 insertions(+), 4 deletions(-) diff --git a/core/config/challenge.go b/core/config/challenge.go index 7ede17d7..45897016 100644 --- a/core/config/challenge.go +++ b/core/config/challenge.go @@ -3,6 +3,8 @@ package config import ( "errors" "fmt" + "net/mail" + "net/url" "regexp" "strings" @@ -17,6 +19,7 @@ const SERVICE_CONTAINER_DEPS string = "xinetd" const SERVICE_CHALL_RUN_CMD string = "xinetd -dontfork" var challengeNamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,63}$`) +var environmentKeyPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) // This is the beast challenge config file structure // any other field specified in the file other than this structure @@ -105,7 +108,13 @@ func (config *Challenge) ValidateRequiredFields(challdir string) error { return err } else if staticChall { log.Debugf("Challenge provided is a static challenge.") - return nil + if config.Env.StaticContentDir == "" { + config.Env.StaticContentDir = core.PUBLIC + } + if err := validateChallengeDir(challdir, config.Env.StaticContentDir, "static_dir"); err != nil { + return err + } + return config.Metadata.ValidateAssets(challdir, config.Env.StaticContentDir) } err = config.Env.ValidateRequiredFields(config.Metadata.Type, challdir) @@ -113,8 +122,7 @@ func (config *Challenge) ValidateRequiredFields(challdir string) error { log.Debugf("Error while validating `ChallengeEnv`'s required fields : %s", err.Error()) return err } - - return nil + return config.Metadata.ValidateAssets(challdir, config.Env.StaticContentDir) } // This contains challenge meta data @@ -176,6 +184,26 @@ func (config *ChallengeMetadata) ValidateRequiredFields() (error, bool) { if !challengeNamePattern.MatchString(config.Name) { return fmt.Errorf("challenge name must match %s", challengeNamePattern.String()), false } + if config.MaxPoints > 0 && config.MinPoints > config.MaxPoints { + return fmt.Errorf("minPoints cannot exceed maxPoints"), false + } + if config.MaxPoints == 0 && config.Points > 0 && config.MinPoints > config.Points { + return fmt.Errorf("minPoints cannot exceed points when maxPoints is omitted"), false + } + if config.MaxPoints > 0 && config.Points > config.MaxPoints { + return fmt.Errorf("points cannot exceed maxPoints"), false + } + for _, prerequisite := range config.PreReqs { + if !challengeNamePattern.MatchString(prerequisite) { + return fmt.Errorf("invalid prerequisite challenge name %q", prerequisite), false + } + } + for _, link := range config.AdditionalLinks { + parsed, err := url.ParseRequestURI(link) + if err != nil || (parsed.Scheme != "https" && parsed.Scheme != "http") || parsed.Host == "" { + return fmt.Errorf("invalid additional link %q", link), false + } + } // Checks if fail solve limit is provided and is greater than 0 if config.MaxAttemptLimit < 0 { @@ -205,6 +233,29 @@ func (config *ChallengeMetadata) ValidateRequiredFields() (error, bool) { return fmt.Errorf("not a valid challenge type : %s", config.Type), false } +func (config *ChallengeMetadata) ValidateAssets(challengeDir, staticContentDir string) error { + if len(config.Assets) == 0 { + return nil + } + if staticContentDir == "" { + staticContentDir = core.PUBLIC + } + staticRoot, err := utils.ResolvePathWithin(challengeDir, staticContentDir) + if err != nil { + return fmt.Errorf("invalid static asset root: %w", err) + } + for _, asset := range config.Assets { + assetPath, err := utils.ResolvePathWithin(staticRoot, asset) + if err != nil { + return fmt.Errorf("invalid challenge asset %q: %w", asset, err) + } + if err := utils.ValidateFileExists(assetPath); err != nil { + return fmt.Errorf("invalid challenge asset %q: %w", asset, err) + } + } + return nil +} + // This contains challenge specific properties which includes the following toml fields // // ```toml @@ -436,6 +487,9 @@ func (config *ChallengeEnv) ValidateRequiredFields(challType string, challdir st } for _, env := range config.EnvironmentVars { + if !environmentKeyPattern.MatchString(env.Key) { + return fmt.Errorf("invalid environment variable key %q", env.Key) + } if err := validateChallengeFile(challdir, env.Value, "environment variable value"); err != nil { return err } @@ -462,6 +516,16 @@ func (config *ChallengeEnv) ExtractPorts() error { if len(config.Ports) > int(core.MAX_PORT_PER_CHALL) { return fmt.Errorf("max ports allowed for challenge : %d given : %d", core.MAX_PORT_PER_CHALL, len(config.Ports)) } + seen := make(map[uint32]bool, len(config.Ports)) + for _, port := range config.Ports { + if port == 0 || port > 65535 { + return fmt.Errorf("container port %d is outside 1-65535", port) + } + if seen[port] { + return fmt.Errorf("container port %d is duplicated", port) + } + seen[port] = true + } if config.DefaultPort == 0 { config.DefaultPort = config.Ports[0] @@ -482,11 +546,14 @@ func (config *ChallengeEnv) ExtractPortsCompose(challdir string) error { } portVariables, err := utils.ExtractPortsFromCompose(composePath) if err != nil { - log.Warnf("failed to extract port variables from compose file with the following error : %s", err.Error()) + return fmt.Errorf("extract compose ports: %w", err) } if len(portVariables) == 0 { return errors.New("some port is required to be specified by the challenge") } + if len(portVariables) > int(core.MAX_PORT_PER_CHALL) { + return fmt.Errorf("max ports allowed for challenge: %d given: %d", core.MAX_PORT_PER_CHALL, len(portVariables)) + } config.PortVariables = portVariables if config.DefaultPortVar == "" { @@ -522,6 +589,10 @@ func (config *Author) ValidateRequiredFields() error { if config.Email == "" { return errors.New("challenge author email is required") } + address, err := mail.ParseAddress(config.Email) + if err != nil || address.Address != config.Email { + return fmt.Errorf("invalid challenge author email %q", config.Email) + } if config.Name == "" { config.Name = core.DEFAULT_AUTHOR_NAME diff --git a/core/config/utils_test.go b/core/config/utils_test.go index a44f8d85..f935446f 100644 --- a/core/config/utils_test.go +++ b/core/config/utils_test.go @@ -5,6 +5,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/sdslabs/beastv4/core" ) func TestLoadChallengeConfigRejectsUnknownKeys(t *testing.T) { @@ -205,3 +207,68 @@ func TestGetAvailableChallengeTypesIsStable(t *testing.T) { seen[challengeType] = true } } + +func TestChallengeMetadataRejectsInvalidScoringAndLinks(t *testing.T) { + tests := []ChallengeMetadata{ + {Name: "challenge", Flag: "flag", Type: "static", MinPoints: 200, MaxPoints: 100}, + {Name: "challenge", Flag: "flag", Type: "static", Points: 100, MinPoints: 200}, + {Name: "challenge", Flag: "flag", Type: "static", Points: 200, MaxPoints: 100}, + {Name: "challenge", Flag: "flag", Type: "static", PreReqs: []string{"../escape"}}, + {Name: "challenge", Flag: "flag", Type: "static", AdditionalLinks: []string{"javascript:alert(1)"}}, + } + for _, metadata := range tests { + if err, _ := metadata.ValidateRequiredFields(); err == nil { + t.Fatalf("expected invalid metadata error: %+v", metadata) + } + } +} + +func TestChallengeEnvRejectsInvalidPortsAndEnvironmentKeys(t *testing.T) { + for _, ports := range [][]uint32{{0}, {80, 80}, {65536}} { + env := ChallengeEnv{Ports: ports} + if err := env.ExtractPorts(); err == nil { + t.Fatalf("expected invalid ports error: %v", ports) + } + } + + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "value"), nil, 0600); err != nil { + t.Fatal(err) + } + previous := Cfg + Cfg = &BeastConfig{AllowedBaseImages: []string{core.DEFAULT_BASE_IMAGE}} + defer func() { Cfg = previous }() + env := ChallengeEnv{ + Ports: []uint32{8080}, + RunCmd: "true", + EnvironmentVars: []EnvironmentVar{{Key: "BAD-NAME", Value: "value"}}, + } + if err := env.ValidateRequiredFields("bare", dir); err == nil || !strings.Contains(err.Error(), "environment variable key") { + t.Fatalf("expected invalid environment key error, got %v", err) + } +} + +func TestAuthorRequiresCanonicalEmail(t *testing.T) { + for _, email := range []string{"not-an-email", "Author "} { + author := Author{Email: email} + if err := author.ValidateRequiredFields(); err == nil { + t.Fatalf("expected invalid email error for %q", email) + } + } +} + +func TestChallengeAssetsStayInsideStaticRoot(t *testing.T) { + parent := t.TempDir() + challenge := filepath.Join(parent, "challenge") + static := filepath.Join(challenge, "static") + if err := os.MkdirAll(static, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(parent, "secret"), nil, 0600); err != nil { + t.Fatal(err) + } + metadata := ChallengeMetadata{Assets: []string{"../../secret"}} + if err := metadata.ValidateAssets(challenge, "static"); err == nil || !strings.Contains(err.Error(), "escapes root") { + t.Fatalf("expected escaping asset error, got %v", err) + } +} From 8ace7417cc6afbb59cffd414a9e3720b2c3cdd36 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Thu, 16 Jul 2026 21:33:35 +0530 Subject: [PATCH 055/149] fix(challenges): honor configured default port Signed-off-by: vibhatsu --- core/config/challenge.go | 3 +++ core/config/utils_test.go | 7 +++++++ 2 files changed, 10 insertions(+) diff --git a/core/config/challenge.go b/core/config/challenge.go index 45897016..5e2746a5 100644 --- a/core/config/challenge.go +++ b/core/config/challenge.go @@ -351,6 +351,9 @@ func (config *ChallengeEnv) TrafficType() cr.TrafficType { // GetDefaultPort returns the default port used by the challenge from the challenge environment // configuration. func (config *ChallengeEnv) GetDefaultPort() uint32 { + if config.DefaultPort != 0 { + return config.DefaultPort + } ports := config.Ports if len(ports) == 0 { return 0 diff --git a/core/config/utils_test.go b/core/config/utils_test.go index f935446f..e34b8f1d 100644 --- a/core/config/utils_test.go +++ b/core/config/utils_test.go @@ -272,3 +272,10 @@ func TestChallengeAssetsStayInsideStaticRoot(t *testing.T) { t.Fatalf("expected escaping asset error, got %v", err) } } + +func TestGetDefaultPortHonorsConfiguredPort(t *testing.T) { + env := ChallengeEnv{Ports: []uint32{8080, 9000}, DefaultPort: 9000} + if got := env.GetDefaultPort(); got != 9000 { + t.Fatalf("GetDefaultPort() = %d, want 9000", got) + } +} From a7586f950162343b17fe422cb7b3dd105eb1855e Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Thu, 16 Jul 2026 21:39:30 +0530 Subject: [PATCH 056/149] fix(config): constrain worker and Git identifiers Signed-off-by: vibhatsu --- core/config/config.go | 55 +++++++++++++++++++++++---------------- core/config/utils_test.go | 29 +++++++++++++++++++++ 2 files changed, 62 insertions(+), 22 deletions(-) diff --git a/core/config/config.go b/core/config/config.go index b2df72d2..9ee7cc7c 100644 --- a/core/config/config.go +++ b/core/config/config.go @@ -4,6 +4,7 @@ import ( "crypto/tls" "errors" "fmt" + "net" "net/url" "os" "path/filepath" @@ -18,6 +19,11 @@ import ( log "github.com/sirupsen/logrus" ) +var configIdentifierPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`) +var hostnamePattern = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251}[A-Za-z0-9])?$`) +var scpGitURLPattern = regexp.MustCompile(`^[^@\s]+@[^:\s]+:[^\s]+$`) +var gitBranchPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$`) + // This is the global beast configuration structure // // # An example of a config file @@ -228,8 +234,8 @@ func (config *BeastConfig) ValidateConfig() error { } for name, server := range config.AvailableServers { - if strings.Contains(name, ":") { - return fmt.Errorf("server key %q contains invalid character ':'", name) + if !configIdentifierPattern.MatchString(name) { + return fmt.Errorf("server key %q is not a valid identifier", name) } server.Name = name @@ -237,31 +243,31 @@ func (config *BeastConfig) ValidateConfig() error { if server.Active { err := server.ValidateServerConfig() if err != nil { - return fmt.Errorf("error while validating config : %s", server.Host) + return fmt.Errorf("validate server %q: %w", name, err) } } } - _, err = url.Parse(config.BeastStaticUrl) - - if err != nil { - return fmt.Errorf("invalid beast static URL provided : %s", config.BeastStaticUrl) + if config.BeastStaticUrl != "" { + staticURL, err := url.ParseRequestURI(config.BeastStaticUrl) + if err != nil || (staticURL.Scheme != "http" && staticURL.Scheme != "https") || staticURL.Host == "" { + return fmt.Errorf("invalid beast static URL provided: %s", config.BeastStaticUrl) + } } if !utils.StringInSlice(core.DEFAULT_BASE_IMAGE, config.AllowedBaseImages) { config.AllowedBaseImages = append(config.AllowedBaseImages, core.DEFAULT_BASE_IMAGE) } - if config.JWTSecret == "" { - log.Error("The secret string is empty in beast config") - return fmt.Errorf("invalid config") + if len(config.JWTSecret) < 32 { + return fmt.Errorf("jwt_secret must contain at least 32 bytes") } for _, gitRemote := range config.GitRemotes { if gitRemote.Active { err := gitRemote.ValidateGitConfig() if err != nil { - return fmt.Errorf("error while validating config : %s", gitRemote.RemoteName) + return fmt.Errorf("validate git remote %q: %w", gitRemote.RemoteName, err) } } } @@ -319,7 +325,7 @@ func (config *BeastConfig) ValidateConfig() error { func (config *BeastConfig) UseLocalDockerDaemon(serverName string) bool { server, ok := config.AvailableServers[serverName] if !ok { - return true + return false } return server.Host == core.LOCALHOST || server.Host == core.LOCALHOST_IP } @@ -339,6 +345,9 @@ func (config *AvailableServer) ValidateServerConfig() error { return fmt.Errorf("host is empty") } config.Host = strings.TrimSpace(config.Host) + if net.ParseIP(config.Host) == nil && !hostnamePattern.MatchString(config.Host) { + return fmt.Errorf("host %q is not a valid IP address or hostname", config.Host) + } err := ValidatePortRange(config.PortRange) if err != nil { @@ -397,20 +406,22 @@ func (config *GitRemote) ValidateGitConfig() error { return err } - gitUrlRegexp, err := regexp.Compile(config.Url) - if err != nil { - eMsg := fmt.Errorf("error while compiling git url regex : %s", err) - return eMsg - } - - if !gitUrlRegexp.MatchString(config.Url) { - return errors.New("the provided git url is not valid") + if !configIdentifierPattern.MatchString(config.RemoteName) { + return fmt.Errorf("git remote name %q is not a valid identifier", config.RemoteName) } - if config.Branch == "" { - log.Warnf("branch for git remote not provided, using %s", core.GIT_REMOTE_DEFAULT_BRANCH) config.Branch = core.GIT_REMOTE_DEFAULT_BRANCH } + if !gitBranchPattern.MatchString(config.Branch) || strings.Contains(config.Branch, "..") || strings.Contains(config.Branch, "@{") { + return fmt.Errorf("git branch %q is not valid", config.Branch) + } + validURL := scpGitURLPattern.MatchString(config.Url) + if parsed, err := url.Parse(config.Url); err == nil && parsed.Scheme == "ssh" && parsed.Host != "" && parsed.Path != "" { + validURL = true + } + if !validURL { + return errors.New("the provided git url is not valid") + } err = utils.ValidateSecretFile(config.Secret) log.Debugf("Using git ssh secret : %s", config.Secret) diff --git a/core/config/utils_test.go b/core/config/utils_test.go index e34b8f1d..e83ef594 100644 --- a/core/config/utils_test.go +++ b/core/config/utils_test.go @@ -279,3 +279,32 @@ func TestGetDefaultPortHonorsConfiguredPort(t *testing.T) { t.Fatalf("GetDefaultPort() = %d, want 9000", got) } } + +func TestUnknownServerDoesNotUseLocalDocker(t *testing.T) { + config := BeastConfig{AvailableServers: map[string]AvailableServer{ + "localhost": {Host: "localhost", Active: true}, + }} + if config.UseLocalDockerDaemon("missing") { + t.Fatal("unknown server silently selected the local Docker daemon") + } +} + +func TestGitRemoteRejectsUnsafeIdentifiersAndURLs(t *testing.T) { + tests := []GitRemote{ + {Url: "git@example.com:repo.git", RemoteName: "../../escape", Branch: "main", Secret: "key"}, + {Url: "git@example.com:repo.git", RemoteName: "origin", Branch: "../main", Secret: "key"}, + {Url: "https://example.com/repo.git", RemoteName: "origin", Branch: "main", Secret: "key"}, + } + for _, remote := range tests { + if err := remote.ValidateGitConfig(); err == nil { + t.Fatalf("expected unsafe git remote error: %+v", remote) + } + } +} + +func TestServerRejectsInvalidHostname(t *testing.T) { + server := AvailableServer{Host: "host;id", PortRange: "10000:20000", Active: true} + if err := server.ValidateServerConfig(); err == nil { + t.Fatal("expected invalid hostname error") + } +} From 7e28a3640468470d44c746791d14e386bfc20189 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 12:40:11 +0530 Subject: [PATCH 057/149] fix(config): remove unsafe live reload Signed-off-by: vibhatsu --- api/config.go | 25 ------------------------- api/router.go | 1 - core/config/config.go | 16 +--------------- 3 files changed, 1 insertion(+), 41 deletions(-) diff --git a/api/config.go b/api/config.go index 8d786025..b75972ef 100644 --- a/api/config.go +++ b/api/config.go @@ -15,31 +15,6 @@ import ( log "github.com/sirupsen/logrus" ) -// This reloads the beast global configuration -// @Summary Reloads any changes in beast global configuration, located at ~/.beast/config.toml. -// @Description Populates beast gobal config map by reparsing the config file $HOME/.beast/config.toml. -// @Tags config -// @Accept json -// @Produce json -// @Param Authorization header string true "Bearer" -// @Success 200 {object} api.HTTPPlainResp -// @Failure 400 {object} api.HTTPPlainResp -// @Router /api/config/reload/ [patch] -func reloadBeastConfig(c *gin.Context) { - err := config.ReloadBeastConfig() - if err != nil { - log.Errorf("%s", err) - c.JSON(http.StatusBadRequest, HTTPPlainResp{ - Message: err.Error(), - }) - return - } - - c.JSON(http.StatusOK, HTTPPlainResp{ - Message: "CONFIG RELOAD SUCCESSFUL", - }) -} - // This updates competition info in the beast global configuration // @Summary Updates competition info in the beast global configuration, located at ~/.beast/config.toml. // @Description Populates beast gobal config map by reparsing the config file $HOME/.beast/config.toml. diff --git a/api/router.go b/api/router.go index 2f6a1f59..37548189 100644 --- a/api/router.go +++ b/api/router.go @@ -115,7 +115,6 @@ func initGinRouter() *gin.Engine { configGroup := apiGroup.Group("/config", adminAuthorize) { - configGroup.PATCH("/reload", reloadBeastConfig) configGroup.POST("/competition-info", updateCompetitionInfoHandler) configGroup.POST("/challenge-info", updateChallengeInfoHandler) } diff --git a/core/config/config.go b/core/config/config.go index 9ee7cc7c..c3851ae5 100644 --- a/core/config/config.go +++ b/core/config/config.go @@ -589,7 +589,7 @@ var NoCache bool func InitConfig() { log.Info("Loading up beast configuration.") if Cfg != nil { - log.Warn("Config is already initialized, reinitilize/reload using ReloadBeastConfig method") + log.Warn("Config is already initialized; restart Beast to load configuration changes") return } configPath := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_CONFIG_FILE_NAME) @@ -602,17 +602,3 @@ func InitConfig() { Cfg = &cfg } - -// ReloadBeastConfig reloads the beast configuration and reinitializes the Cfg global -// variable. -func ReloadBeastConfig() error { - configPath := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_CONFIG_FILE_NAME) - cfg, err := LoadBeastConfig(configPath) - - if err != nil { - return fmt.Errorf("error while loading beast config: %s", err) - } - - Cfg = &cfg - return nil -} From f9ea84586e919320a4a9a7ce8f91649b2677cfe4 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 12:41:56 +0530 Subject: [PATCH 058/149] fix(uploads): bound challenge archive extraction Signed-off-by: vibhatsu --- core/manager/archive_test.go | 90 ++++++++++++++++++++++++++++++ core/manager/utils.go | 105 ++++++++++++++++++++++++++--------- 2 files changed, 168 insertions(+), 27 deletions(-) create mode 100644 core/manager/archive_test.go diff --git a/core/manager/archive_test.go b/core/manager/archive_test.go new file mode 100644 index 00000000..6744ff74 --- /dev/null +++ b/core/manager/archive_test.go @@ -0,0 +1,90 @@ +package manager + +import ( + "archive/zip" + "os" + "path/filepath" + "testing" +) + +type archiveEntry struct { + name string + mode os.FileMode + body string +} + +func writeTestArchive(t *testing.T, entries []archiveEntry) string { + t.Helper() + archivePath := filepath.Join(t.TempDir(), "challenge.zip") + file, err := os.Create(archivePath) + if err != nil { + t.Fatal(err) + } + writer := zip.NewWriter(file) + for _, entry := range entries { + header := &zip.FileHeader{Name: entry.name, Method: zip.Deflate} + header.SetMode(entry.mode) + part, err := writer.CreateHeader(header) + if err != nil { + t.Fatal(err) + } + if _, err := part.Write([]byte(entry.body)); err != nil { + t.Fatal(err) + } + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } + return archivePath +} + +func TestUnzipChallengeFolderExtractsRegularFiles(t *testing.T) { + archivePath := writeTestArchive(t, []archiveEntry{{ + name: "challenge/beast.toml", + mode: 0600, + body: "[challenge]", + }}) + destination := t.TempDir() + + extracted, err := UnzipChallengeFolder(archivePath, destination) + if err != nil { + t.Fatal(err) + } + contents, err := os.ReadFile(filepath.Join(extracted, "challenge", "beast.toml")) + if err != nil { + t.Fatal(err) + } + if string(contents) != "[challenge]" { + t.Fatalf("unexpected contents: %q", contents) + } +} + +func TestUnzipChallengeFolderRejectsUnsafeEntries(t *testing.T) { + tests := []archiveEntry{ + {name: "../escape", mode: 0600, body: "bad"}, + {name: "/absolute", mode: 0600, body: "bad"}, + {name: `windows\\escape`, mode: 0600, body: "bad"}, + {name: "link", mode: os.ModeSymlink | 0777, body: "target"}, + } + for _, entry := range tests { + t.Run(entry.name, func(t *testing.T) { + archivePath := writeTestArchive(t, []archiveEntry{entry}) + if _, err := UnzipChallengeFolder(archivePath, t.TempDir()); err == nil { + t.Fatal("expected unsafe archive entry to be rejected") + } + }) + } +} + +func TestUnzipChallengeFolderRejectsDuplicatePaths(t *testing.T) { + archivePath := writeTestArchive(t, []archiveEntry{ + {name: "challenge/flag", mode: 0600, body: "first"}, + {name: "challenge/flag", mode: 0600, body: "second"}, + }) + if _, err := UnzipChallengeFolder(archivePath, t.TempDir()); err == nil { + t.Fatal("expected duplicate archive path to be rejected") + } +} diff --git a/core/manager/utils.go b/core/manager/utils.go index 079da65e..5171a2d3 100644 --- a/core/manager/utils.go +++ b/core/manager/utils.go @@ -4,7 +4,6 @@ import ( "archive/zip" "bytes" "fmt" - "github.com/sdslabs/beastv4/core/cache" "io" "io/ioutil" "os" @@ -14,6 +13,7 @@ import ( "strings" "text/template" + "github.com/sdslabs/beastv4/core/cache" "github.com/sdslabs/beastv4/pkg/auth" "github.com/sdslabs/beastv4/pkg/remoteManager" @@ -27,6 +27,11 @@ import ( log "github.com/sirupsen/logrus" ) +const ( + maxChallengeArchiveFiles = 4096 + maxChallengeArchiveBytes = 512 << 20 +) + type BeastBareDockerfile struct { DockerBaseImage string Ports string @@ -752,71 +757,117 @@ func ExtractChallengeNamesFromFileNames(fileNames []string) []string { // Unzips challenge folder in a destination directory func UnzipChallengeFolder(zipContextPath, dstPath string) (string, error) { - baseFileName := filepath.Base(zipContextPath) targetDir := filepath.Join(dstPath, strings.TrimSuffix(baseFileName, filepath.Ext(baseFileName))) - if err := os.MkdirAll(targetDir, os.ModePerm); err != nil { - log.Fatal(err) - } - - // 1. Open the zip file reader, err := zip.OpenReader(zipContextPath) if err != nil { return "", err } defer reader.Close() - // 2. Get the absolute destination path destination, err := filepath.Abs(targetDir) if err != nil { return "", err } + if _, err := os.Lstat(destination); err == nil { + return "", fmt.Errorf("archive destination already exists: %s", destination) + } else if !os.IsNotExist(err) { + return "", err + } + if err := os.MkdirAll(destination, 0700); err != nil { + return "", err + } + complete := false + defer func() { + if !complete { + _ = os.RemoveAll(destination) + } + }() - // 3. Iterate over zip files inside the archive and unzip each of them + if len(reader.File) > maxChallengeArchiveFiles { + return "", fmt.Errorf("archive contains too many entries: %d (maximum %d)", len(reader.File), maxChallengeArchiveFiles) + } + + var totalSize uint64 + seen := make(map[string]struct{}, len(reader.File)) for _, f := range reader.File { - err := unzipFile(f, destination) + if f.UncompressedSize64 > maxChallengeArchiveBytes-totalSize { + return "", fmt.Errorf("archive expands beyond the %d-byte limit", maxChallengeArchiveBytes) + } + totalSize += f.UncompressedSize64 + + relPath, err := safeArchivePath(f.Name) if err != nil { return "", err } + if _, exists := seen[relPath]; exists { + return "", fmt.Errorf("archive contains duplicate path %q", f.Name) + } + seen[relPath] = struct{}{} + + if err := unzipFile(f, destination, relPath); err != nil { + return "", err + } } + complete = true return targetDir, nil } -func unzipFile(f *zip.File, destination string) error { - // 4. Check if file paths are not vulnerable to [Zip Slip](https://snyk.io/research/zip-slip-vulnerability) - filePath := filepath.Join(destination, f.Name) - if !strings.HasPrefix(filePath, filepath.Clean(destination)+string(os.PathSeparator)) { - return fmt.Errorf("invalid file path: %s", filePath) +func safeArchivePath(name string) (string, error) { + if name == "" || strings.ContainsRune(name, '\x00') || strings.Contains(name, `\`) { + return "", fmt.Errorf("archive contains invalid path %q", name) + } + relPath := filepath.Clean(filepath.FromSlash(name)) + if relPath == "." || filepath.IsAbs(relPath) || relPath == ".." || strings.HasPrefix(relPath, ".."+string(os.PathSeparator)) { + return "", fmt.Errorf("archive path escapes destination: %q", name) } + return relPath, nil +} + +func unzipFile(f *zip.File, destination, relPath string) error { + filePath := filepath.Join(destination, relPath) - // 5. Create directory tree if f.FileInfo().IsDir() { - if err := os.MkdirAll(filePath, os.ModePerm); err != nil { - return err - } - return nil + return os.MkdirAll(filePath, 0755) + } + if !f.Mode().IsRegular() { + return fmt.Errorf("archive contains unsupported entry %q with mode %s", f.Name, f.Mode()) } - if err := os.MkdirAll(filepath.Dir(filePath), os.ModePerm); err != nil { + if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil { return err } - // 6. Create a destination file for unzipped content - destinationFile, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) + mode := f.Mode().Perm() & 0777 + destinationFile, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode) if err != nil { return err } - defer destinationFile.Close() - // 7. Unzip the content of a file and copy it to the destination file zippedFile, err := f.Open() if err != nil { + _ = destinationFile.Close() return err } - defer zippedFile.Close() - if _, err := io.Copy(destinationFile, zippedFile); err != nil { + written, copyErr := io.Copy(destinationFile, io.LimitReader(zippedFile, int64(f.UncompressedSize64)+1)) + closeSourceErr := zippedFile.Close() + closeDestinationErr := destinationFile.Close() + if copyErr != nil { + return copyErr + } + if closeSourceErr != nil { + return closeSourceErr + } + if closeDestinationErr != nil { + return closeDestinationErr + } + if written != int64(f.UncompressedSize64) { + _ = os.Remove(filePath) + return fmt.Errorf("archive entry %q size does not match its header", f.Name) + } + if err := os.Chmod(filePath, mode); err != nil { return err } return nil From 89e4679a2ff2f8d253c582e20ea35252842e69f3 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 12:43:27 +0530 Subject: [PATCH 059/149] fix(uploads): fail closed while copying challenges Signed-off-by: vibhatsu --- core/manager/archive_test.go | 17 +++++++++ core/manager/utils.go | 74 +++++++++++++++++++----------------- 2 files changed, 56 insertions(+), 35 deletions(-) diff --git a/core/manager/archive_test.go b/core/manager/archive_test.go index 6744ff74..c8fef6e2 100644 --- a/core/manager/archive_test.go +++ b/core/manager/archive_test.go @@ -88,3 +88,20 @@ func TestUnzipChallengeFolderRejectsDuplicatePaths(t *testing.T) { t.Fatal("expected duplicate archive path to be rejected") } } + +func TestCopyDirRejectsSymlinksAndExistingDestinations(t *testing.T) { + source := t.TempDir() + if err := os.WriteFile(filepath.Join(source, "file"), []byte("content"), 0600); err != nil { + t.Fatal(err) + } + if err := os.Symlink("file", filepath.Join(source, "link")); err != nil { + t.Fatal(err) + } + + if err := CopyDir(source, filepath.Join(t.TempDir(), "copy")); err == nil { + t.Fatal("expected symbolic link to be rejected") + } + if err := CopyDir(source, t.TempDir()); err == nil { + t.Fatal("expected existing destination to be rejected") + } +} diff --git a/core/manager/utils.go b/core/manager/utils.go index 5171a2d3..144b39f8 100644 --- a/core/manager/utils.go +++ b/core/manager/utils.go @@ -7,7 +7,6 @@ import ( "io" "io/ioutil" "os" - "path" "path/filepath" "sort" "strings" @@ -873,61 +872,66 @@ func unzipFile(f *zip.File, destination, relPath string) error { return nil } -// File copies a single file from src to dst +// CopyFile copies one regular file without following links or replacing a path. func CopyFile(src, dst string) error { - var err error - var srcfd *os.File - var dstfd *os.File - var srcinfo os.FileInfo - - if srcfd, err = os.Open(src); err != nil { + srcInfo, err := os.Lstat(src) + if err != nil { return err } - defer srcfd.Close() + if !srcInfo.Mode().IsRegular() { + return fmt.Errorf("source is not a regular file: %s", src) + } - if dstfd, err = os.Create(dst); err != nil { + srcFile, err := os.Open(src) + if err != nil { return err } - defer dstfd.Close() + defer srcFile.Close() - if _, err = io.Copy(dstfd, srcfd); err != nil { + dstFile, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, srcInfo.Mode().Perm()) + if err != nil { return err } - if srcinfo, err = os.Stat(src); err != nil { + if _, err := io.Copy(dstFile, srcFile); err != nil { + _ = dstFile.Close() + _ = os.Remove(dst) return err } - return os.Chmod(dst, srcinfo.Mode()) + if err := dstFile.Close(); err != nil { + _ = os.Remove(dst) + return err + } + return nil } -// Dir copies a whole directory recursively -func CopyDir(src string, dst string) error { - var err error - var fds []os.FileInfo - var srcinfo os.FileInfo - - if srcinfo, err = os.Stat(src); err != nil { +// CopyDir copies a directory tree without following links or replacing paths. +func CopyDir(src, dst string) error { + srcInfo, err := os.Lstat(src) + if err != nil { return err } + if !srcInfo.IsDir() || srcInfo.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("source is not a directory: %s", src) + } - if err = os.MkdirAll(dst, srcinfo.Mode()); err != nil { + if err := os.Mkdir(dst, srcInfo.Mode().Perm()); err != nil { return err } - - if fds, err = ioutil.ReadDir(src); err != nil { + entries, err := os.ReadDir(src) + if err != nil { return err } - for _, fd := range fds { - srcfp := path.Join(src, fd.Name()) - dstfp := path.Join(dst, fd.Name()) - - if fd.IsDir() { - if err = CopyDir(srcfp, dstfp); err != nil { - fmt.Println(err) - } - } else { - if err = CopyFile(srcfp, dstfp); err != nil { - fmt.Println(err) + for _, entry := range entries { + srcPath := filepath.Join(src, entry.Name()) + dstPath := filepath.Join(dst, entry.Name()) + if entry.IsDir() { + if err := CopyDir(srcPath, dstPath); err != nil { + return err } + continue + } + if err := CopyFile(srcPath, dstPath); err != nil { + return err } } return nil From b7fb4784fbe4b471dfce46ae1c282f31dd6e332c Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 12:46:29 +0530 Subject: [PATCH 060/149] fix(api): stage challenge uploads safely Signed-off-by: vibhatsu --- api/manage.go | 169 ++++++++++++++++++++++++++++++++++----------- api/upload_test.go | 59 ++++++++++++++++ 2 files changed, 188 insertions(+), 40 deletions(-) create mode 100644 api/upload_test.go diff --git a/api/manage.go b/api/manage.go index 225075a6..1b512e4c 100644 --- a/api/manage.go +++ b/api/manage.go @@ -1,11 +1,15 @@ package api import ( + "errors" "fmt" + "io" + "mime/multipart" "net/http" "os" "path/filepath" "strings" + "sync" "time" "github.com/gin-gonic/gin" @@ -19,6 +23,13 @@ import ( log "github.com/sirupsen/logrus" ) +const ( + maxChallengeUploadBytes int64 = 256 << 20 + maxChallengeUploadRequestBytes = maxChallengeUploadBytes + 1<<20 +) + +var challengeUploadMu sync.Mutex + // Handles route related to manage all the challenges or the challenges related to a particular tag for current beast remote. // @Summary Handles challenge management actions for multiple challenges. // @Description Handles challenge management routes for multiple the challenges with actions which includes - DEPLOY, UNDEPLOY. @@ -436,40 +447,46 @@ func manageScheduledAction(c *gin.Context) { // @Failure 500 {object} api.HTTPErrorResp // @Router /api/manage/challenge/upload [post] func manageUploadHandler(c *gin.Context) { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxChallengeUploadRequestBytes) file, err := c.FormFile("file") - // The file cannot be received. if err != nil { - c.AbortWithStatusJSON(http.StatusBadRequest, HTTPPlainResp{ - Message: "no file received from user", + status := http.StatusBadRequest + var maxBytesErr *http.MaxBytesError + if errors.As(err, &maxBytesErr) { + status = http.StatusRequestEntityTooLarge + } + c.AbortWithStatusJSON(status, HTTPPlainResp{ + Message: "a ZIP challenge archive is required", }) return } - - if err = utils.CreateIfNotExistDir(core.BEAST_TEMP_DIR); err != nil { - if err := os.MkdirAll(core.BEAST_TEMP_DIR, 0755); err != nil { - c.JSON(http.StatusInternalServerError, HTTPErrorResp{ - Error: fmt.Sprintf("Could not create dir %s: %s", core.BEAST_TEMP_DIR, err), - }) - } + archiveName, err := challengeArchiveFilename(file.Filename) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, HTTPErrorResp{Error: err.Error()}) + return } - zipContextPath := filepath.Join(core.BEAST_TEMP_DIR, file.Filename) - - // The file is received, save it - if err := c.SaveUploadedFile(file, zipContextPath); err != nil { + tempRoot, err := os.MkdirTemp("", "beast-upload-") + if err != nil { c.AbortWithStatusJSON(http.StatusInternalServerError, HTTPErrorResp{ - Error: fmt.Sprintf("Unable to save file: %s", err), + Error: fmt.Sprintf("Unable to create upload staging directory: %s", err), }) return } + defer os.RemoveAll(tempRoot) - // Extract and show from zip and return response - tempStageDir, err := manager.UnzipChallengeFolder(zipContextPath, core.BEAST_TEMP_DIR) - - // log.Debug("The dir is ",tempStageDir) + zipContextPath := filepath.Join(tempRoot, archiveName) + if err := saveChallengeArchive(file, zipContextPath); err != nil { + status := http.StatusBadRequest + if errors.Is(err, errChallengeUploadTooLarge) { + status = http.StatusRequestEntityTooLarge + } + c.AbortWithStatusJSON(status, HTTPErrorResp{Error: err.Error()}) + return + } - // The file cannot be successfully un-zipped or the resultant was a malformed directory + tempStageDir, err := manager.UnzipChallengeFolder(zipContextPath, tempRoot) if err != nil { c.JSON(http.StatusBadRequest, HTTPErrorResp{ Error: fmt.Sprintf("The unzip process failed or the ZIP was unacceptable: %s", err), @@ -477,34 +494,29 @@ func manageUploadHandler(c *gin.Context) { return } - err = manager.ValidateChallengeConfig(tempStageDir) - if err != nil { - c.JSON(http.StatusOK, HTTPErrorResp{ + if err := manager.ValidateChallengeConfig(tempStageDir); err != nil { + c.JSON(http.StatusBadRequest, HTTPErrorResp{ Error: err.Error(), }) - } - - challengeUploadDirectory := filepath.Join( - core.BEAST_GLOBAL_DIR, - core.BEAST_UPLOADS_DIR, - strings.TrimSuffix(file.Filename, filepath.Ext(file.Filename)), - ) - - if err = manager.CopyDir(tempStageDir, challengeUploadDirectory); err != nil { - c.AbortWithStatusJSON(http.StatusInternalServerError, HTTPErrorResp{ - Error: fmt.Sprintf("Unable to move challenge directory: %s", err), - }) return } - challengeName := filepath.Base(challengeUploadDirectory) - configFile := filepath.Join(challengeUploadDirectory, core.CHALLENGE_CONFIG_FILE_NAME) - + configFile := filepath.Join(tempStageDir, core.CHALLENGE_CONFIG_FILE_NAME) config, err := cfg.LoadChallengeConfig(configFile) if err != nil { - log.Errorf("Error while loading beast config for challenge %s : %s", challengeName, err) + log.Errorf("Error while loading uploaded challenge config: %s", err) c.JSON(http.StatusBadRequest, HTTPErrorResp{ - Error: fmt.Sprintf("CONFIG ERROR: %s : %s", challengeName, err), + Error: fmt.Sprintf("CONFIG ERROR: %s", err), + }) + return + } + if err := persistUploadedChallenge(tempStageDir, config.Challenge.Metadata.Name); err != nil { + status := http.StatusInternalServerError + if errors.Is(err, os.ErrExist) { + status = http.StatusConflict + } + c.AbortWithStatusJSON(status, HTTPErrorResp{ + Error: fmt.Sprintf("Unable to store challenge: %s", err), }) return } @@ -522,6 +534,83 @@ func manageUploadHandler(c *gin.Context) { }) } +var errChallengeUploadTooLarge = errors.New("challenge archive exceeds the 256 MiB limit") + +func challengeArchiveFilename(name string) (string, error) { + if name == "" || name != filepath.Base(name) || strings.Contains(name, `\`) { + return "", fmt.Errorf("invalid challenge archive filename") + } + if !strings.EqualFold(filepath.Ext(name), ".zip") || strings.TrimSuffix(name, filepath.Ext(name)) == "" { + return "", fmt.Errorf("challenge archive must have a non-empty .zip filename") + } + return name, nil +} + +func saveChallengeArchive(header *multipart.FileHeader, destination string) error { + if header.Size < 0 || header.Size > maxChallengeUploadBytes { + return errChallengeUploadTooLarge + } + source, err := header.Open() + if err != nil { + return err + } + defer source.Close() + + target, err := os.OpenFile(destination, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if err != nil { + return err + } + written, copyErr := io.Copy(target, io.LimitReader(source, maxChallengeUploadBytes+1)) + closeErr := target.Close() + if copyErr != nil { + return copyErr + } + if closeErr != nil { + return closeErr + } + if written > maxChallengeUploadBytes { + return errChallengeUploadTooLarge + } + return nil +} + +func persistUploadedChallenge(source, challengeName string) error { + uploadsRoot := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_UPLOADS_DIR) + if err := os.MkdirAll(uploadsRoot, 0700); err != nil { + return err + } + rootInfo, err := os.Lstat(uploadsRoot) + if err != nil { + return err + } + if !rootInfo.IsDir() || rootInfo.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("uploads root is not a regular directory") + } + if err := os.Chmod(uploadsRoot, 0700); err != nil { + return err + } + + stageRoot, err := os.MkdirTemp(uploadsRoot, ".upload-") + if err != nil { + return err + } + defer os.RemoveAll(stageRoot) + stagedChallenge := filepath.Join(stageRoot, challengeName) + if err := manager.CopyDir(source, stagedChallenge); err != nil { + return err + } + + challengeUploadMu.Lock() + defer challengeUploadMu.Unlock() + destination := filepath.Join(uploadsRoot, challengeName) + if _, err := os.Lstat(destination); err == nil { + return fmt.Errorf("challenge %q is already uploaded: %w", challengeName, os.ErrExist) + } else if !os.IsNotExist(err) { + return err + } + return os.Rename(stagedChallenge, destination) +} + func validateFlagHandler(c *gin.Context) { flag := c.PostForm("flag") challenge_name := c.PostForm("challenge_name") diff --git a/api/upload_test.go b/api/upload_test.go new file mode 100644 index 00000000..e15d2eaf --- /dev/null +++ b/api/upload_test.go @@ -0,0 +1,59 @@ +package api + +import ( + "errors" + "mime/multipart" + "os" + "path/filepath" + "testing" + + "github.com/sdslabs/beastv4/core" +) + +func TestChallengeArchiveFilenameRejectsUnsafeNames(t *testing.T) { + for _, name := range []string{"", ".zip", "challenge.tar", "../challenge.zip", `dir\\challenge.zip`} { + if _, err := challengeArchiveFilename(name); err == nil { + t.Fatalf("expected filename %q to be rejected", name) + } + } + if got, err := challengeArchiveFilename("challenge.ZIP"); err != nil || got != "challenge.ZIP" { + t.Fatalf("expected valid ZIP name, got %q, %v", got, err) + } +} + +func TestSaveChallengeArchiveRejectsOversizeHeader(t *testing.T) { + header := &multipart.FileHeader{Filename: "challenge.zip", Size: maxChallengeUploadBytes + 1} + err := saveChallengeArchive(header, filepath.Join(t.TempDir(), "challenge.zip")) + if !errors.Is(err, errChallengeUploadTooLarge) { + t.Fatalf("expected upload size error, got %v", err) + } +} + +func TestPersistUploadedChallengeDoesNotReplaceExistingChallenge(t *testing.T) { + previousGlobalDir := core.BEAST_GLOBAL_DIR + core.BEAST_GLOBAL_DIR = t.TempDir() + t.Cleanup(func() { core.BEAST_GLOBAL_DIR = previousGlobalDir }) + + source := t.TempDir() + if err := os.WriteFile(filepath.Join(source, "beast.toml"), []byte("original"), 0600); err != nil { + t.Fatal(err) + } + if err := persistUploadedChallenge(source, "challenge"); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(source, "beast.toml"), []byte("replacement"), 0600); err != nil { + t.Fatal(err) + } + if err := persistUploadedChallenge(source, "challenge"); !errors.Is(err, os.ErrExist) { + t.Fatalf("expected existing challenge error, got %v", err) + } + + stored := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_UPLOADS_DIR, "challenge", "beast.toml") + contents, err := os.ReadFile(stored) + if err != nil { + t.Fatal(err) + } + if string(contents) != "original" { + t.Fatalf("existing challenge was replaced: %q", contents) + } +} From 1e8bbe05dd5fc50e11fd66227daf7f7c015e74dd Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 12:51:46 +0530 Subject: [PATCH 061/149] fix(config): remove unsafe runtime mutation Signed-off-by: vibhatsu --- api/config.go | 95 ------------------------------------------- api/router.go | 1 - core/config/config.go | 49 ++-------------------- 3 files changed, 3 insertions(+), 142 deletions(-) diff --git a/api/config.go b/api/config.go index b75972ef..4b1dbcbe 100644 --- a/api/config.go +++ b/api/config.go @@ -3,110 +3,15 @@ package api import ( "fmt" "net/http" - "os" - "path/filepath" "strconv" "strings" "github.com/gin-gonic/gin" "github.com/sdslabs/beastv4/core" - "github.com/sdslabs/beastv4/core/config" "github.com/sdslabs/beastv4/core/database" log "github.com/sirupsen/logrus" ) -// This updates competition info in the beast global configuration -// @Summary Updates competition info in the beast global configuration, located at ~/.beast/config.toml. -// @Description Populates beast gobal config map by reparsing the config file $HOME/.beast/config.toml. -// @Tags config -// @Accept json -// @Produce json -// @Param name formData string true "Competition Name" -// @Param about formData string true "Some information about competition" -// @Param prizes formData string false "Competitions Prizes for the winners" -// @Param starting_time formData string true "Competition's starting time" -// @Param ending_time formData string true "Competition's ending time" -// @Param timezone formData string true "Competition's timezone" -// @Param logo formData file false "Competition's logo" -// @Success 200 {object} api.HTTPPlainResp -// @Failure 400 {object} api.HTTPPlainResp -// @Failure 500 {object} api.HTTPErrorResp -// @Router /api/config/competition-info [post] -func updateCompetitionInfoHandler(c *gin.Context) { - var logoFilePath string - - name := c.PostForm("name") - about := c.PostForm("about") - prizes := c.PostForm("prizes") - starting_time := c.PostForm("starting_time") - ending_time := c.PostForm("ending_time") - timezone := c.PostForm("timezone") - logo, err := c.FormFile("logo") - - // The file cannot be received. - if err != nil { - log.Info("No file recieved from the user") - } else { - logoFilePath = filepath.Join( - core.BEAST_GLOBAL_DIR, - core.BEAST_ASSETS_DIR, - core.BEAST_LOGO_DIR, - logo.Filename, - ) - - competitionInfo, err := config.GetCompetitionInfo() - if err != nil { - log.Info("Unable to load previous config") - c.JSON(http.StatusInternalServerError, HTTPErrorResp{ - Error: fmt.Sprintf("Unable to load previous config: %s", err), - }) - return - } - - // Delete previously uploaded logo file - if competitionInfo.LogoURL != "" { - if err := os.Remove(competitionInfo.LogoURL); err != nil { - log.Info("Unable to delete previous logo file") - c.JSON(http.StatusInternalServerError, HTTPErrorResp{ - Error: fmt.Sprintf("Unable to delete previous logo file: %s", err), - }) - return - } - } - - // The file is received, save it - if err := c.SaveUploadedFile(logo, logoFilePath); err != nil { - c.JSON(http.StatusInternalServerError, HTTPErrorResp{ - Error: fmt.Sprintf("Unable to save file: %s", err), - }) - return - } - } - - configInfo := config.CompetitionInfo{ - Name: name, - About: about, - Prizes: prizes, - StartingTime: starting_time, - EndingTime: ending_time, - TimeZone: timezone, - LogoURL: logoFilePath, - } - - err = config.UpdateCompetitionInfo(&configInfo) - if err != nil { - c.JSON(http.StatusBadRequest, HTTPPlainResp{ - Message: err.Error(), - }) - return - } - - c.JSON(http.StatusOK, HTTPPlainResp{ - Message: "Competition information updated successfully", - }) - return -} - // This updates challenge info in the respective challenge configuration // @Summary Updates challenge info in the database, located at ~/.beast/beast.db. // @Description Updates challenge info in the database, located at ~/.beast/beast.db. diff --git a/api/router.go b/api/router.go index 37548189..df0f4c1a 100644 --- a/api/router.go +++ b/api/router.go @@ -115,7 +115,6 @@ func initGinRouter() *gin.Engine { configGroup := apiGroup.Group("/config", adminAuthorize) { - configGroup.POST("/competition-info", updateCompetitionInfoHandler) configGroup.POST("/challenge-info", updateChallengeInfoHandler) } diff --git a/core/config/config.go b/core/config/config.go index c3851ae5..6615bb1c 100644 --- a/core/config/config.go +++ b/core/config/config.go @@ -15,7 +15,6 @@ import ( "github.com/sdslabs/beastv4/core" "github.com/sdslabs/beastv4/utils" - "github.com/BurntSushi/toml" log "github.com/sirupsen/logrus" ) @@ -493,53 +492,11 @@ type MailConfig struct { SMTPPort string `toml:"smtpPort"` } -func UpdateCompetitionInfo(competitionInfo *CompetitionInfo) error { - configPath := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_CONFIG_FILE_NAME) - var config BeastConfig - - err := utils.ValidateFileExists(configPath) - if err != nil { - return err - } - - _, err = toml.DecodeFile(configPath, &config) - if err != nil { - return err - } - - config.CompetitionInfo = *competitionInfo - - configFile, err := os.Create(configPath) - if err != nil { - return err - } - - if err := toml.NewEncoder(configFile).Encode(config); err != nil { - return err - } - - if err := configFile.Close(); err != nil { - return err - } - return err -} - func GetCompetitionInfo() (CompetitionInfo, error) { - configPath := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_CONFIG_FILE_NAME) - var config BeastConfig - var competitionInfo CompetitionInfo - - err := utils.ValidateFileExists(configPath) - if err != nil { - return competitionInfo, err + if Cfg == nil { + return CompetitionInfo{}, errors.New("beast config is not initialized") } - - _, err = toml.DecodeFile(configPath, &config) - if err != nil { - return competitionInfo, err - } - - return config.CompetitionInfo, nil + return Cfg.CompetitionInfo, nil } // From the path of the config file provided as an arguement this function From a8525505690fb15b6460b5156ff8fd550e573b49 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 12:53:11 +0530 Subject: [PATCH 062/149] fix(config): return initialization errors Signed-off-by: vibhatsu --- api/main.go | 4 +++- cmd/beast/challenge.go | 5 ++++- cmd/beast/challenge_logs.go | 6 +++++- cmd/beast/create_admin_author.go | 17 +++++++++++++---- cmd/beast/healthprobe.go | 5 ++++- cmd/beast/init.go | 12 +++++++++--- cmd/beast/verify.go | 5 ++++- core/config/config.go | 12 +++++------- core/config/init_test.go | 25 +++++++++++++++++++++++++ 9 files changed, 72 insertions(+), 19 deletions(-) create mode 100644 core/config/init_test.go diff --git a/api/main.go b/api/main.go index 79004dd7..e11d6ba3 100644 --- a/api/main.go +++ b/api/main.go @@ -86,7 +86,9 @@ func newHTTPServer(address string, handler http.Handler) *http.Server { func RunBeastApiServer(ctx context.Context, port, defaultauthorpassword string, autoDeploy, healthProbe, periodicSync bool, noCache bool) error { log.Info("Bootstrapping Beast API server") - config.InitConfig() + if err := config.InitConfig(); err != nil { + return err + } address, err := listenAddress(port) if err != nil { diff --git a/cmd/beast/challenge.go b/cmd/beast/challenge.go index 057444e2..c16da7a3 100644 --- a/cmd/beast/challenge.go +++ b/cmd/beast/challenge.go @@ -19,7 +19,10 @@ var challengeCmd = &cobra.Command{ Long: "Performs actions like : deploy, undeploy, redeploy, purge to the challs", Args: cobra.MinimumNArgs(1), Run: func(cmd *cobra.Command, args []string) { - config.InitConfig() + if err := config.InitConfig(); err != nil { + log.Error(err) + return + } // Since action is already verfied to exist it does not make sense to check // its existence here therefore we directly parse the action from the command. diff --git a/cmd/beast/challenge_logs.go b/cmd/beast/challenge_logs.go index f92012fc..0d2f58f3 100644 --- a/cmd/beast/challenge_logs.go +++ b/cmd/beast/challenge_logs.go @@ -3,6 +3,7 @@ package main import ( "github.com/sdslabs/beastv4/core/config" "github.com/sdslabs/beastv4/core/utils" + log "github.com/sirupsen/logrus" "github.com/spf13/cobra" ) @@ -12,7 +13,10 @@ var logsCmd = &cobra.Command{ Args: cobra.MinimumNArgs(1), Run: func(cmd *cobra.Command, args []string) { - config.InitConfig() + if err := config.InitConfig(); err != nil { + log.Error(err) + return + } utils.GetLogs(args[0], true) }, diff --git a/cmd/beast/create_admin_author.go b/cmd/beast/create_admin_author.go index 6b8d9c45..d50c46d5 100644 --- a/cmd/beast/create_admin_author.go +++ b/cmd/beast/create_admin_author.go @@ -11,10 +11,13 @@ import ( "github.com/spf13/cobra" ) -func createAuthorAdminPrereq() { - config.InitConfig() +func createAuthorAdminPrereq() error { + if err := config.InitConfig(); err != nil { + return err + } auth.Init(core.ITERATIONS, core.HASH_LENGTH, core.TIMEPERIOD, core.ISSUER, config.Cfg.JWTSecret, []string{core.USER_ROLES["author"], core.USER_ROLES["maintainer"]}, []string{core.USER_ROLES["admin"]}, []string{core.USER_ROLES["contestant"]}) + return nil } var createAuthorCmd = &cobra.Command{ @@ -43,7 +46,10 @@ var createAuthorCmd = &cobra.Command{ }, Run: func(cmd *cobra.Command, args []string) { - createAuthorAdminPrereq() + if err := createAuthorAdminPrereq(); err != nil { + fmt.Printf("Unable to initialize configuration: %v\n", err) + return + } if err := utils.CreateAdminOrAuthor(Name, Username, Email, Password, "author"); err != nil { fmt.Printf("Unable to create author: %v\n", err) } @@ -76,7 +82,10 @@ var createAdminCmd = &cobra.Command{ }, Run: func(cmd *cobra.Command, args []string) { - createAuthorAdminPrereq() + if err := createAuthorAdminPrereq(); err != nil { + fmt.Printf("Unable to initialize configuration: %v\n", err) + return + } if err := utils.CreateAdminOrAuthor(Name, Username, Email, Password, "admin"); err != nil { fmt.Printf("Unable to create admin: %v\n", err) } diff --git a/cmd/beast/healthprobe.go b/cmd/beast/healthprobe.go index dd319906..4926d7f1 100644 --- a/cmd/beast/healthprobe.go +++ b/cmd/beast/healthprobe.go @@ -19,7 +19,10 @@ var healthProbeCmd = &cobra.Command{ Long: "Run Health Probe only without API server", Run: func(cmd *cobra.Command, args []string) { - config.InitConfig() + if err := config.InitConfig(); err != nil { + log.Error(err) + return + } controllerLock, err := acquireControllerLock(core.BEAST_GLOBAL_DIR) if err != nil { log.Error(err) diff --git a/cmd/beast/init.go b/cmd/beast/init.go index 9cc2eb6a..7821a54b 100644 --- a/cmd/beast/init.go +++ b/cmd/beast/init.go @@ -261,7 +261,9 @@ func initDb() error { func initAdmin() error { if result := utils.PromptBinary("Create an administrative user for beast?"); result { - config.InitConfig() + if err := config.InitConfig(); err != nil { + return err + } name := utils.PromptString("Enter admin name") if name == "" { @@ -285,7 +287,9 @@ func initAdmin() error { database.Init() - createAuthorAdminPrereq() + if err := createAuthorAdminPrereq(); err != nil { + return err + } if err := coreUtils.CreateAdminOrAuthor(name, username, email, password, "admin"); err != nil { return err } @@ -321,7 +325,9 @@ func runBeastBootsteps() error { log.Infoln("Successfully installed air for live reloading...") - config.InitConfig() + if err := config.InitConfig(); err != nil { + return err + } if err := initCache(); err != nil { return err diff --git a/cmd/beast/verify.go b/cmd/beast/verify.go index 8ef4bddd..d26cc687 100644 --- a/cmd/beast/verify.go +++ b/cmd/beast/verify.go @@ -15,7 +15,10 @@ var verifyCmd = &cobra.Command{ Args: cobra.MinimumNArgs(1), Run: func(cmd *cobra.Command, args []string) { - config.InitConfig() + if err := config.InitConfig(); err != nil { + log.Error(err) + return + } challengeName := args[0] challengeDir := coreUtils.GetChallengeDir(challengeName) diff --git a/core/config/config.go b/core/config/config.go index 6615bb1c..220053e3 100644 --- a/core/config/config.go +++ b/core/config/config.go @@ -541,21 +541,19 @@ func LoadBeastConfig(configPath string) (BeastConfig, error) { var Cfg *BeastConfig var NoCache bool -// InitConfig loads the config from the global config file and populate -// the Cfg global variable used everywhere else. -func InitConfig() { +// InitConfig loads the config from the global config file and populates Cfg. +func InitConfig() error { log.Info("Loading up beast configuration.") if Cfg != nil { log.Warn("Config is already initialized; restart Beast to load configuration changes") - return + return nil } configPath := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_CONFIG_FILE_NAME) cfg, err := LoadBeastConfig(configPath) - if err != nil { - log.Errorf("Error while loading the beast global config : %s", err) - os.Exit(1) + return fmt.Errorf("load Beast global config: %w", err) } Cfg = &cfg + return nil } diff --git a/core/config/init_test.go b/core/config/init_test.go new file mode 100644 index 00000000..842f0844 --- /dev/null +++ b/core/config/init_test.go @@ -0,0 +1,25 @@ +package config + +import ( + "testing" + + "github.com/sdslabs/beastv4/core" +) + +func TestInitConfigReturnsLoadError(t *testing.T) { + previousCfg := Cfg + previousGlobalDir := core.BEAST_GLOBAL_DIR + Cfg = nil + core.BEAST_GLOBAL_DIR = t.TempDir() + t.Cleanup(func() { + Cfg = previousCfg + core.BEAST_GLOBAL_DIR = previousGlobalDir + }) + + if err := InitConfig(); err == nil { + t.Fatal("expected missing configuration error") + } + if Cfg != nil { + t.Fatal("configuration was initialized after a load error") + } +} From e4f2c022b8bd31fb1caa3fc81c5d5acbca7de274 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 12:56:00 +0530 Subject: [PATCH 063/149] fix(database): fail startup on initialization errors Signed-off-by: vibhatsu --- api/main.go | 4 +- cmd/beast/init.go | 4 +- core/database/database.go | 152 ++++++++++++++++++--------------- core/database/database_test.go | 30 +++++++ 4 files changed, 119 insertions(+), 71 deletions(-) create mode 100644 core/database/database_test.go diff --git a/api/main.go b/api/main.go index e11d6ba3..a5c49943 100644 --- a/api/main.go +++ b/api/main.go @@ -100,7 +100,9 @@ func RunBeastApiServer(ctx context.Context, port, defaultauthorpassword string, auth.Init(core.ITERATIONS, core.HASH_LENGTH, core.TIMEPERIOD, core.ISSUER, config.Cfg.JWTSecret, []string{core.USER_ROLES["author"], core.USER_ROLES["maintainer"]}, []string{core.USER_ROLES["admin"]}, []string{core.USER_ROLES["contestant"]}) remoteManager.Init() - database.Init() + if err := database.Init(); err != nil { + return err + } cache.Init() backgroundCtx, stopBackground := context.WithCancel(ctx) dynamicScoreDone := startDynamicScoreWorker(backgroundCtx) diff --git a/cmd/beast/init.go b/cmd/beast/init.go index 7821a54b..ee536aa3 100644 --- a/cmd/beast/init.go +++ b/cmd/beast/init.go @@ -285,7 +285,9 @@ func initAdmin() error { return errors.New("admin password is required") } - database.Init() + if err := database.Init(); err != nil { + return err + } if err := createAuthorAdminPrereq(); err != nil { return err diff --git a/core/database/database.go b/core/database/database.go index ecffb56f..8b15c952 100644 --- a/core/database/database.go +++ b/core/database/database.go @@ -3,14 +3,16 @@ package database import ( "crypto/rand" "fmt" + "net" + "net/url" "os" "os/exec" "path/filepath" "sync" "time" - "github.com/BurntSushi/toml" "github.com/sdslabs/beastv4/core" + beastConfig "github.com/sdslabs/beastv4/core/config" "github.com/sdslabs/beastv4/pkg/auth" "github.com/sdslabs/beastv4/utils" log "github.com/sirupsen/logrus" @@ -21,44 +23,46 @@ import ( var ( DBMux *sync.Mutex Db *gorm.DB - dberr error ) -var ( - BEAST_GLOBAL_DIR string = filepath.Join(os.Getenv("HOME"), ".beast") - dbConfig Config -) +var dbConfig beastConfig.PsqlConfig -type Config struct { - PsqlConf PSQLConfig `toml:"psql_config"` -} -type PSQLConfig struct { - User string `toml:"user"` - Password string `toml:"password"` - Dbname string `toml:"dbname"` - Host string `toml:"host"` - Port string `toml:"port"` - SslMode string `toml:"sslmode"` +func LoadDbConfig() error { + if beastConfig.Cfg != nil { + dbConfig = beastConfig.Cfg.PsqlConf + return nil + } + cfg, err := beastConfig.LoadBeastConfig(filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_CONFIG_FILE_NAME)) + if err != nil { + return fmt.Errorf("load database config: %w", err) + } + dbConfig = cfg.PsqlConf + return nil } -// Db config is loaded separately here for temp use because init() function is -// called during initialization of package. -// It is also loaded during db backup/reset -func LoadDbConfig() { - if _, err := toml.DecodeFile(filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_CONFIG_FILE_NAME), &dbConfig); err != nil { - log.Fatalf("Error loading TOML file: %v", err) +func postgresDSN(config beastConfig.PsqlConfig) string { + dsn := &url.URL{ + Scheme: "postgresql", + User: url.UserPassword(config.User, config.Password), + Host: net.JoinHostPort(config.Host, config.Port), + Path: config.Dbname, } + query := dsn.Query() + query.Set("sslmode", config.SslMode) + dsn.RawQuery = query.Encode() + return dsn.String() } // Connect psql database func ConnectDatabase() error { - LoadDbConfig() - dsn := fmt.Sprintf("user=%s password=%s dbname=%s host=%s port=%s sslmode=%s", dbConfig.PsqlConf.User, dbConfig.PsqlConf.Password, dbConfig.PsqlConf.Dbname, dbConfig.PsqlConf.Host, dbConfig.PsqlConf.Port, dbConfig.PsqlConf.SslMode) - Db, dberr = gorm.Open(postgres.Open(dsn), &gorm.Config{}) - if dberr != nil { - log.Error("Error while initializing the database.", dberr) - return dberr + if err := LoadDbConfig(); err != nil { + return err } + db, err := gorm.Open(postgres.Open(postgresDSN(dbConfig)), &gorm.Config{}) + if err != nil { + return err + } + Db = db log.Debug("Database initialized") return nil } @@ -67,26 +71,25 @@ func ConnectDatabase() error { // Postgresql database for beast. The Db variable is the connection variable for the // database, which is not closed after creating a connection here and can // be used further after this. -func Init() { +func Init() error { DBMux = &sync.Mutex{} if Db == nil { - dberr = ConnectDatabase() - if dberr != nil { - log.Error("Error while initializing the database.", dberr) + if err := ConnectDatabase(); err != nil { + return fmt.Errorf("initialize database: %w", err) } } if err := Db.SetupJoinTable(&Challenge{}, "Users", &UserChallenges{}); err != nil { - log.Fatalf("Cannot create related models: %s", err) + return fmt.Errorf("configure user challenge relation: %w", err) } if err := Db.SetupJoinTable(&User{}, "Challenges", &ChallengeMaintainer{}); err != nil { - log.Fatalf("Cannot create related models: %s", err) + return fmt.Errorf("configure user maintainer relation: %w", err) } if err := Db.SetupJoinTable(&Challenge{}, "Users", &ChallengeMaintainer{}); err != nil { - log.Fatalf("Cannot create related models: %s", err) + return fmt.Errorf("configure challenge maintainer relation: %w", err) } if err := Db.SetupJoinTable(&User{}, "Hints", &UserHint{}); err != nil { - log.Fatalf("Cannot create related models: %s", err) + return fmt.Errorf("configure user hint relation: %w", err) } // UserHint must be explicitly migrated since GORM's AutoMigrate on User only handles @@ -94,18 +97,18 @@ func Init() { // challenge_id columns on user_hints won't be added to existing databases. err := Db.AutoMigrate(&Challenge{}, &Transaction{}, &Port{}, &User{}, &UserChallenges{}, &ChallengeMaintainer{}, &Tag{}, &Notification{}, &Hint{}, &DynamicFlag{}, &DynamicFlagClaim{}, &DynamicScoreDirty{}, &OTP{}, &UserHint{}) if err != nil { - log.Fatalf("failed to migrate database with error: %s", err) + return fmt.Errorf("migrate database: %w", err) } if err := MigrateSubmissionGuards(); err != nil { - log.Fatalf("failed to migrate submission guards with error: %s", err) + return fmt.Errorf("migrate submission guards: %w", err) } if err := MigrateChallengeMaintainers(); err != nil { - log.Fatalf("failed to migrate challenge maintainers with error: %s", err) + return fmt.Errorf("migrate challenge maintainers: %w", err) } users, err := QueryUserEntries("email", core.DEFAULT_USER_EMAIL) if err != nil { - log.Fatalf("Error while checking dummy user entry.") + return fmt.Errorf("check dummy user entry: %w", err) } if len(users) == 0 { @@ -113,11 +116,11 @@ func Init() { randPass := make([]byte, 32) if _, err := rand.Read(randPass); err != nil { - log.Fatalf("failed to generate dummy user password: %s", err) + return fmt.Errorf("generate dummy user password: %w", err) } authModel, err := auth.CreateModel(core.DEFAULT_USER_NAME, string(randPass), core.USER_ROLES["author"]) if err != nil { - log.Fatalf("failed to hash dummy user password: %s", err) + return fmt.Errorf("hash dummy user password: %w", err) } err = CreateUserEntry(&User{ @@ -127,14 +130,17 @@ func Init() { }) if err != nil { - log.Errorf("Error while creating dummy user entry.") - os.Exit(1) + return fmt.Errorf("create dummy user entry: %w", err) } } + return nil } func BackupAndReset() { - LoadDbConfig() + if err := LoadDbConfig(); err != nil { + log.Error(err) + return + } err := BackupDatabase() if err != nil { @@ -180,8 +186,10 @@ func BackupAndReset() { } func BackupDatabase() error { - if dbConfig == (Config{}) { - LoadDbConfig() + if dbConfig == (beastConfig.PsqlConfig{}) { + if err := LoadDbConfig(); err != nil { + return err + } } backupPath := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_BACKUP_DIR, core.DB_BACKUP_DIR) @@ -191,9 +199,9 @@ func BackupDatabase() error { return err } - backupFile := fmt.Sprintf("%s_%s.bak", dbConfig.PsqlConf.Dbname, time.Now().Format("20060102150405")) - cmd := exec.Command("pg_dump", "-U", dbConfig.PsqlConf.User, "-h", dbConfig.PsqlConf.Host, "-p", dbConfig.PsqlConf.Port, "-F", "c", "-f", filepath.Join(backupPath, backupFile), dbConfig.PsqlConf.Dbname) - cmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", dbConfig.PsqlConf.Password)) + backupFile := fmt.Sprintf("%s_%s.bak", dbConfig.Dbname, time.Now().Format("20060102150405")) + cmd := exec.Command("pg_dump", "-U", dbConfig.User, "-h", dbConfig.Host, "-p", dbConfig.Port, "-F", "c", "-f", filepath.Join(backupPath, backupFile), dbConfig.Dbname) + cmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", dbConfig.Password)) output, err := cmd.CombinedOutput() if err != nil { log.Printf("Backup error: %s\n", string(output)) @@ -204,8 +212,10 @@ func BackupDatabase() error { } func ResetDatabase() error { - if dbConfig == (Config{}) { - LoadDbConfig() + if dbConfig == (beastConfig.PsqlConfig{}) { + if err := LoadDbConfig(); err != nil { + return err + } } err := TerminateDatabaseConnections() if err != nil { @@ -213,8 +223,8 @@ func ResetDatabase() error { return err } - dropCmd := exec.Command("dropdb", "-U", dbConfig.PsqlConf.User, "-h", dbConfig.PsqlConf.Host, "-p", dbConfig.PsqlConf.Port, "--force", dbConfig.PsqlConf.Dbname) - dropCmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", dbConfig.PsqlConf.Password)) + dropCmd := exec.Command("dropdb", "-U", dbConfig.User, "-h", dbConfig.Host, "-p", dbConfig.Port, "--force", dbConfig.Dbname) + dropCmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", dbConfig.Password)) output, err := dropCmd.CombinedOutput() if err != nil { @@ -222,8 +232,8 @@ func ResetDatabase() error { return err } - createCmd := exec.Command("psql", "-U", dbConfig.PsqlConf.User, "-h", dbConfig.PsqlConf.Host, "-p", dbConfig.PsqlConf.Port, "-d", "postgres", "-c", "CREATE DATABASE "+dbConfig.PsqlConf.Dbname+";") - createCmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", dbConfig.PsqlConf.Password)) + createCmd := exec.Command("psql", "-U", dbConfig.User, "-h", dbConfig.Host, "-p", dbConfig.Port, "-d", "postgres", "-c", "CREATE DATABASE "+dbConfig.Dbname+";") + createCmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", dbConfig.Password)) output, err = createCmd.CombinedOutput() if err != nil { @@ -236,19 +246,21 @@ func ResetDatabase() error { // Terminate all active connections before dropping func TerminateDatabaseConnections() error { - if dbConfig == (Config{}) { - LoadDbConfig() + if dbConfig == (beastConfig.PsqlConfig{}) { + if err := LoadDbConfig(); err != nil { + return err + } } terminateCmd := exec.Command( "psql", - "-U", dbConfig.PsqlConf.User, - "-h", dbConfig.PsqlConf.Host, - "-p", dbConfig.PsqlConf.Port, + "-U", dbConfig.User, + "-h", dbConfig.Host, + "-p", dbConfig.Port, "-d", "postgres", "-c", - fmt.Sprintf("SELECT pg_terminate_backend(pg_stat_activity.pid) FROM pg_stat_activity WHERE datname = '%s' AND pid <> pg_backend_pid();", dbConfig.PsqlConf.Dbname), + fmt.Sprintf("SELECT pg_terminate_backend(pg_stat_activity.pid) FROM pg_stat_activity WHERE datname = '%s' AND pid <> pg_backend_pid();", dbConfig.Dbname), ) - terminateCmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", dbConfig.PsqlConf.Password)) + terminateCmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", dbConfig.Password)) output, err := terminateCmd.CombinedOutput() outputStr := string(output) @@ -261,7 +273,9 @@ func TerminateDatabaseConnections() error { } func RestoreDatabase(backupFile string) error { - LoadDbConfig() + if err := LoadDbConfig(); err != nil { + return err + } err := TerminateDatabaseConnections() if err != nil { @@ -276,16 +290,16 @@ func RestoreDatabase(backupFile string) error { restoreCmd := exec.Command( "pg_restore", - "-U", dbConfig.PsqlConf.User, - "-h", dbConfig.PsqlConf.Host, - "-p", dbConfig.PsqlConf.Port, - "-d", dbConfig.PsqlConf.Dbname, + "-U", dbConfig.User, + "-h", dbConfig.Host, + "-p", dbConfig.Port, + "-d", dbConfig.Dbname, "--no-owner", "--clean", "--if-exists", backupFile, ) - restoreCmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", dbConfig.PsqlConf.Password)) + restoreCmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", dbConfig.Password)) output, err := restoreCmd.CombinedOutput() if err != nil { diff --git a/core/database/database_test.go b/core/database/database_test.go new file mode 100644 index 00000000..5513f45a --- /dev/null +++ b/core/database/database_test.go @@ -0,0 +1,30 @@ +package database + +import ( + "net/url" + "testing" + + "github.com/sdslabs/beastv4/core/config" +) + +func TestPostgresDSNEncodesCredentials(t *testing.T) { + dsn := postgresDSN(config.PsqlConfig{ + User: "beast user", + Password: "secret with =' delimiters", + Dbname: "beast-db", + Host: "127.0.0.1", + Port: "5432", + SslMode: "require", + }) + parsed, err := url.Parse(dsn) + if err != nil { + t.Fatal(err) + } + password, _ := parsed.User.Password() + if parsed.User.Username() != "beast user" || password != "secret with =' delimiters" { + t.Fatalf("credentials did not round trip through DSN: %s", dsn) + } + if parsed.Query().Get("sslmode") != "require" { + t.Fatalf("sslmode did not round trip through DSN: %s", dsn) + } +} From b1acfcbc26dd18433d505adb3edd5e05a59a8da9 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 12:58:02 +0530 Subject: [PATCH 064/149] fix(config): validate data store endpoints Signed-off-by: vibhatsu --- core/config/config.go | 35 +++++++++++++++++++++++++++++++---- core/config/utils_test.go | 19 +++++++++++++++++++ core/database/database.go | 5 +++-- 3 files changed, 53 insertions(+), 6 deletions(-) diff --git a/core/config/config.go b/core/config/config.go index 220053e3..38850352 100644 --- a/core/config/config.go +++ b/core/config/config.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "regexp" + "strconv" "strings" "time" @@ -450,21 +451,47 @@ type RedisConfig struct { func (config *PsqlConfig) ValidatePsqlConfig() error { if config.User == "" || config.Password == "" || config.Dbname == "" || config.Host == "" || config.Port == "" { - log.Error("One of username, password, dbname, hostname, port is missing in the config") return errors.New("psql config not valid, config parameters missing") } + if !configIdentifierPattern.MatchString(config.User) || !configIdentifierPattern.MatchString(config.Dbname) { + return errors.New("psql user and database names must be safe identifiers") + } + if err := validateServiceAddress(config.Host, config.Port); err != nil { + return fmt.Errorf("invalid psql address: %w", err) + } if config.SslMode == "" { - log.Warn("Ssl Mode not set. Disabling it.") config.SslMode = "prefer" } + validSSLModes := map[string]struct{}{ + "disable": {}, "allow": {}, "prefer": {}, "require": {}, "verify-ca": {}, "verify-full": {}, + } + if _, ok := validSSLModes[config.SslMode]; !ok { + return fmt.Errorf("unsupported psql sslmode %q", config.SslMode) + } return nil } func (config *RedisConfig) ValidateRedisConfig() error { - if config.Host == "" || config.Port == "" { - log.Error("One of hostname or port is missing in the config") + if config.Password == "" || config.Host == "" || config.Port == "" { return errors.New("redis config not valid, config parameters missing") } + if config.User != "" && !configIdentifierPattern.MatchString(config.User) { + return errors.New("redis user must be a safe identifier") + } + if err := validateServiceAddress(config.Host, config.Port); err != nil { + return fmt.Errorf("invalid redis address: %w", err) + } + return nil +} + +func validateServiceAddress(host, port string) error { + if net.ParseIP(host) == nil && !hostnamePattern.MatchString(host) { + return fmt.Errorf("invalid host %q", host) + } + portNumber, err := strconv.ParseUint(port, 10, 16) + if err != nil || portNumber == 0 { + return fmt.Errorf("invalid port %q", port) + } return nil } diff --git a/core/config/utils_test.go b/core/config/utils_test.go index e83ef594..d382fc87 100644 --- a/core/config/utils_test.go +++ b/core/config/utils_test.go @@ -308,3 +308,22 @@ func TestServerRejectsInvalidHostname(t *testing.T) { t.Fatal("expected invalid hostname error") } } + +func TestDataStoreConfigRejectsUnsafeValues(t *testing.T) { + psql := PsqlConfig{User: "beast", Password: "secret", Dbname: "../../escape", Host: "localhost", Port: "5432", SslMode: "prefer"} + if err := psql.ValidatePsqlConfig(); err == nil { + t.Fatal("expected unsafe database name error") + } + psql = PsqlConfig{User: "beast", Password: "secret", Dbname: "beast", Host: "localhost", Port: "5432", SslMode: "invalid"} + if err := psql.ValidatePsqlConfig(); err == nil { + t.Fatal("expected invalid sslmode error") + } + redis := RedisConfig{User: "beast", Host: "host;id", Port: "6379", Password: "secret"} + if err := redis.ValidateRedisConfig(); err == nil { + t.Fatal("expected invalid Redis host error") + } + redis = RedisConfig{User: "beast", Host: "localhost", Port: "0", Password: "secret"} + if err := redis.ValidateRedisConfig(); err == nil { + t.Fatal("expected invalid Redis port error") + } +} diff --git a/core/database/database.go b/core/database/database.go index 8b15c952..04b724bd 100644 --- a/core/database/database.go +++ b/core/database/database.go @@ -11,6 +11,7 @@ import ( "sync" "time" + "github.com/lib/pq" "github.com/sdslabs/beastv4/core" beastConfig "github.com/sdslabs/beastv4/core/config" "github.com/sdslabs/beastv4/pkg/auth" @@ -232,7 +233,7 @@ func ResetDatabase() error { return err } - createCmd := exec.Command("psql", "-U", dbConfig.User, "-h", dbConfig.Host, "-p", dbConfig.Port, "-d", "postgres", "-c", "CREATE DATABASE "+dbConfig.Dbname+";") + createCmd := exec.Command("psql", "-U", dbConfig.User, "-h", dbConfig.Host, "-p", dbConfig.Port, "-d", "postgres", "-c", "CREATE DATABASE "+pq.QuoteIdentifier(dbConfig.Dbname)+";") createCmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", dbConfig.Password)) output, err = createCmd.CombinedOutput() @@ -258,7 +259,7 @@ func TerminateDatabaseConnections() error { "-p", dbConfig.Port, "-d", "postgres", "-c", - fmt.Sprintf("SELECT pg_terminate_backend(pg_stat_activity.pid) FROM pg_stat_activity WHERE datname = '%s' AND pid <> pg_backend_pid();", dbConfig.Dbname), + fmt.Sprintf("SELECT pg_terminate_backend(pg_stat_activity.pid) FROM pg_stat_activity WHERE datname = %s AND pid <> pg_backend_pid();", utils.QuoteLiteral(dbConfig.Dbname)), ) terminateCmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", dbConfig.Password)) From 51e517519252970021a34d8c40394951764c48c1 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 13:01:53 +0530 Subject: [PATCH 065/149] fix(cache): fail startup on initialization errors Signed-off-by: vibhatsu --- api/main.go | 5 +- core/cache/cache.go | 125 ++++++++++++++++-------------- core/cache/cache_test.go | 12 +++ core/cache/ports_instance_test.go | 2 +- 4 files changed, 85 insertions(+), 59 deletions(-) create mode 100644 core/cache/cache_test.go diff --git a/api/main.go b/api/main.go index a5c49943..f6c7d37c 100644 --- a/api/main.go +++ b/api/main.go @@ -103,7 +103,10 @@ func RunBeastApiServer(ctx context.Context, port, defaultauthorpassword string, if err := database.Init(); err != nil { return err } - cache.Init() + cache.Configure(config.Cfg.RedisConf.User, config.Cfg.RedisConf.Password, config.Cfg.RedisConf.Host, config.Cfg.RedisConf.Port, config.Cfg.RedisConf.Db) + if err := cache.Init(); err != nil { + return err + } backgroundCtx, stopBackground := context.WithCancel(ctx) dynamicScoreDone := startDynamicScoreWorker(backgroundCtx) instanceCleanupDone := make(chan struct{}) diff --git a/core/cache/cache.go b/core/cache/cache.go index 68bfe7ba..6f56b2fe 100644 --- a/core/cache/cache.go +++ b/core/cache/cache.go @@ -3,6 +3,7 @@ package cache import ( "context" "fmt" + "net" "os" "os/exec" "path/filepath" @@ -11,7 +12,6 @@ import ( "sync" "time" - "github.com/BurntSushi/toml" "github.com/redis/go-redis/v9" "github.com/sdslabs/beastv4/core" "github.com/sdslabs/beastv4/utils" @@ -21,49 +21,54 @@ import ( var ( CacheMutex *sync.Mutex Cache *redis.Client - cacheError error ) -var ( - BEAST_GLOBAL_DIR string = filepath.Join(os.Getenv("HOME"), ".beast") - cacheConfig Config -) +var cacheConfig RedisConfig -type Config struct { - RedisConfig RedisConfig `toml:"redis_config"` -} type RedisConfig struct { - User string `toml:"user"` - Password string `toml:"password"` - Host string `toml:"host"` - Port string `toml:"port"` - DB int `toml:"db"` + User string + Password string + Host string + Port string + DB uint32 +} + +func Configure(user, password, host, port string, db uint32) { + cacheConfig = RedisConfig{User: user, Password: password, Host: host, Port: port, DB: 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) +func LoadCacheConfig() error { + if cacheConfig.Host == "" || cacheConfig.Port == "" || cacheConfig.Password == "" { + return fmt.Errorf("cache is not configured") } + return nil +} + +func redisAddress(config RedisConfig) string { + return net.JoinHostPort(config.Host, config.Port) } // Connect redis func ConnectCache() error { - LoadCacheConfig() - Cache = redis.NewClient(&redis.Options{ - Addr: fmt.Sprintf("%s:%s", cacheConfig.RedisConfig.Host, cacheConfig.RedisConfig.Port), - Username: cacheConfig.RedisConfig.User, - Password: cacheConfig.RedisConfig.Password, - DB: cacheConfig.RedisConfig.DB, + if err := LoadCacheConfig(); err != nil { + return err + } + client := redis.NewClient(&redis.Options{ + Addr: redisAddress(cacheConfig), + Username: cacheConfig.User, + Password: cacheConfig.Password, + DB: int(cacheConfig.DB), }) - _, err := Cache.Ping(context.Background()).Result() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, err := client.Ping(ctx).Result() if err != nil { - return fmt.Errorf("failed to connected to redis: %s", err.Error()) + _ = client.Close() + return fmt.Errorf("connect to Redis: %w", err) } + Cache = client log.Debug("Cache initialized") return nil } @@ -72,14 +77,14 @@ func ConnectCache() error { // Postgresql database for beast. The Db variable is the connection variable for the // database, which is not closed after creating a connection here and can // be used further after this. -func Init() { +func Init() error { CacheMutex = &sync.Mutex{} if Cache == nil { - cacheError = ConnectCache() - if cacheError != nil { - log.Errorf("Error while initializing cache: %s", cacheError.Error()) + if err := ConnectCache(); err != nil { + return fmt.Errorf("initialize cache: %w", err) } } + return nil } func EnableKeyspaceExpiryNotifications() error { @@ -118,7 +123,7 @@ func SubscribeExpiredInstanceMarkers(ctx context.Context, handler func(instanceI return fmt.Errorf("redis cache not initialized") } - pattern := fmt.Sprintf("__keyevent@%d__:expired", cacheConfig.RedisConfig.DB) + pattern := fmt.Sprintf("__keyevent@%d__:expired", cacheConfig.DB) pubsub := Cache.PSubscribe(ctx, pattern) defer pubsub.Close() @@ -161,7 +166,10 @@ func Close() error { } func BackupAndReset() { - LoadCacheConfig() + if err := LoadCacheConfig(); err != nil { + log.Error(err) + return + } err := BackupCache() if err != nil { @@ -207,8 +215,10 @@ func BackupAndReset() { } func BackupCache() error { - if cacheConfig == (Config{}) { - LoadCacheConfig() + if cacheConfig == (RedisConfig{}) { + if err := LoadCacheConfig(); err != nil { + return err + } } backupPath := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_BACKUP_DIR, core.BEAST_CACHE_DIR) @@ -218,24 +228,21 @@ func BackupCache() error { return err } - backupFile := fmt.Sprintf("%d_%s.bak", cacheConfig.RedisConfig.DB, time.Now().Format("20060102150405")) + backupFile := fmt.Sprintf("%d_%s.bak", cacheConfig.DB, time.Now().Format("20060102150405")) args := []string{ - "-h", cacheConfig.RedisConfig.Host, - "-p", cacheConfig.RedisConfig.Port, - "-n", strconv.Itoa(cacheConfig.RedisConfig.DB), + "-h", cacheConfig.Host, + "-p", cacheConfig.Port, + "-n", strconv.FormatUint(uint64(cacheConfig.DB), 10), "--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) + if cacheConfig.User != "" { + args = append(args, "--user", cacheConfig.User) } cmd := exec.Command("redis-cli", args...) - cmd.Env = append(os.Environ(), fmt.Sprintf("REDISCLI_AUTH=%s", cacheConfig.RedisConfig.Password)) + cmd.Env = append(os.Environ(), fmt.Sprintf("REDISCLI_AUTH=%s", cacheConfig.Password)) output, err := cmd.CombinedOutput() if err != nil { log.Printf("Backup error: %s\n", string(output)) @@ -246,8 +253,10 @@ func BackupCache() error { } func ResetCache() error { - if cacheConfig == (Config{}) { - LoadCacheConfig() + if cacheConfig == (RedisConfig{}) { + if err := LoadCacheConfig(); err != nil { + return err + } } err := TerminateCacheConnections() if err != nil { @@ -257,14 +266,14 @@ func ResetCache() error { dropCmd := exec.Command( "redis-cli", - "-h", cacheConfig.RedisConfig.Host, - "-p", cacheConfig.RedisConfig.Port, - "--user", cacheConfig.RedisConfig.User, - "-n", strconv.Itoa(cacheConfig.RedisConfig.DB), + "-h", cacheConfig.Host, + "-p", cacheConfig.Port, + "--user", cacheConfig.User, + "-n", strconv.FormatUint(uint64(cacheConfig.DB), 10), "FLUSHDB", ) - dropCmd.Env = append(os.Environ(), fmt.Sprintf("REDISCLI_AUTH=%s", cacheConfig.RedisConfig.Password)) + dropCmd.Env = append(os.Environ(), fmt.Sprintf("REDISCLI_AUTH=%s", cacheConfig.Password)) output, err := dropCmd.CombinedOutput() if err != nil { @@ -278,12 +287,14 @@ func ResetCache() error { // Terminate all active connections before dropping func TerminateCacheConnections() error { - if cacheConfig == (Config{}) { - LoadCacheConfig() + if cacheConfig == (RedisConfig{}) { + if err := LoadCacheConfig(); err != nil { + return err + } } cache := redis.NewClient(&redis.Options{ - Addr: fmt.Sprintf("%s:%s", cacheConfig.RedisConfig.Host, cacheConfig.RedisConfig.Port), + Addr: redisAddress(cacheConfig), Username: core.REDIS_DEFAULT_USER, Password: utils.PromptSecret("Enter default redis user password"), }) @@ -297,7 +308,7 @@ func TerminateCacheConnections() error { _, err = cache.Do(context.Background(), "CLIENT", "KILL", - "USER", cacheConfig.RedisConfig.User, + "USER", cacheConfig.User, "SKIPME", "yes", ).Result() diff --git a/core/cache/cache_test.go b/core/cache/cache_test.go new file mode 100644 index 00000000..d1683bc7 --- /dev/null +++ b/core/cache/cache_test.go @@ -0,0 +1,12 @@ +package cache + +import ( + "testing" +) + +func TestRedisAddressSupportsIPv6(t *testing.T) { + got := redisAddress(RedisConfig{Host: "2001:db8::1", Port: "6379"}) + if got != "[2001:db8::1]:6379" { + t.Fatalf("redisAddress() = %q", got) + } +} diff --git a/core/cache/ports_instance_test.go b/core/cache/ports_instance_test.go index baefcb66..78da801a 100644 --- a/core/cache/ports_instance_test.go +++ b/core/cache/ports_instance_test.go @@ -41,7 +41,7 @@ func setupRedisIntegrationTest(t *testing.T) func() { Password: os.Getenv("BEAST_TEST_REDIS_PASSWORD"), DB: db, }) - cacheConfig.RedisConfig.DB = db + cacheConfig.DB = uint32(db) ctx := context.Background() if err := Cache.Ping(ctx).Err(); err != nil { From b47ae557935b8b9c5f73df1860463d913aca47f5 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 13:04:23 +0530 Subject: [PATCH 066/149] fix(time): parse competition windows safely Signed-off-by: vibhatsu --- core/config/config.go | 52 +++++++++++++++++++++++++++++++++++ core/config/utils_test.go | 20 ++++++++++++++ core/utils/dateparser.go | 28 ++----------------- core/utils/dateparser_test.go | 21 ++++++++++++++ 4 files changed, 95 insertions(+), 26 deletions(-) create mode 100644 core/utils/dateparser_test.go diff --git a/core/config/config.go b/core/config/config.go index 38850352..9bc9c0a7 100644 --- a/core/config/config.go +++ b/core/config/config.go @@ -218,6 +218,9 @@ func (config *BeastConfig) ValidateConfig() error { if err != nil { return fmt.Errorf("error while validating redis config : %s", err) } + if err := config.CompetitionInfo.Validate(); err != nil { + return fmt.Errorf("validate competition info: %w", err) + } if len(config.AvailableServers) == 0 { log.Warn("No available servers provided for challenges. Using default localhost") @@ -512,6 +515,55 @@ type CompetitionInfo struct { DynamicScore bool `toml:"dynamic_score"` } +func (config CompetitionInfo) Validate() error { + windowConfigured := config.StartingTime != "" || config.EndingTime != "" || config.TimeZone != "" + if !windowConfigured { + if config.DynamicScore { + return errors.New("dynamic scoring requires a competition time window") + } + return nil + } + _, _, err := config.ParseWindow() + return err +} + +func (config CompetitionInfo) ParseWindow() (time.Time, time.Time, error) { + if config.StartingTime == "" || config.EndingTime == "" || config.TimeZone == "" { + return time.Time{}, time.Time{}, errors.New("starting_time, ending_time, and timezone must be configured together") + } + locationName := strings.TrimSpace(strings.SplitN(config.TimeZone, ":", 2)[0]) + location, err := time.LoadLocation(locationName) + if err != nil { + return time.Time{}, time.Time{}, fmt.Errorf("load competition timezone %q: %w", locationName, err) + } + start, err := parseCompetitionTimestamp(config.StartingTime, location) + if err != nil { + return time.Time{}, time.Time{}, fmt.Errorf("parse starting_time: %w", err) + } + end, err := parseCompetitionTimestamp(config.EndingTime, location) + if err != nil { + return time.Time{}, time.Time{}, fmt.Errorf("parse ending_time: %w", err) + } + if !end.After(start) { + return time.Time{}, time.Time{}, errors.New("ending_time must be after starting_time") + } + return start, end, nil +} + +func parseCompetitionTimestamp(value string, location *time.Location) (time.Time, error) { + parts := strings.SplitN(value, ",", 3) + timeFields := strings.Fields(parts[0]) + if len(parts) < 2 || len(timeFields) == 0 { + return time.Time{}, fmt.Errorf("invalid timestamp %q", value) + } + dateAndTime := strings.TrimSpace(parts[1]) + " " + timeFields[0] + parsed, err := time.ParseInLocation("2 January 2006 15:04:05", dateAndTime, location) + if err != nil { + return time.Time{}, err + } + return parsed, nil +} + type MailConfig struct { From string `toml:"from"` Password string `toml:"password"` diff --git a/core/config/utils_test.go b/core/config/utils_test.go index d382fc87..1d37218a 100644 --- a/core/config/utils_test.go +++ b/core/config/utils_test.go @@ -327,3 +327,23 @@ func TestDataStoreConfigRejectsUnsafeValues(t *testing.T) { t.Fatal("expected invalid Redis port error") } } + +func TestCompetitionInfoValidatesTimeWindow(t *testing.T) { + info := CompetitionInfo{ + StartingTime: "00:00:00 UTC: +05:30, 1 January 2030, Tuesday", + EndingTime: "23:59:59 UTC: +05:30, 2 January 2030, Wednesday", + TimeZone: "Asia/Calcutta: UTC +05:30", + } + start, end, err := info.ParseWindow() + if err != nil { + t.Fatal(err) + } + if !end.After(start) { + t.Fatal("parsed competition window is not ordered") + } + + info.EndingTime = "malformed" + if err := info.Validate(); err == nil { + t.Fatal("expected malformed competition time error") + } +} diff --git a/core/utils/dateparser.go b/core/utils/dateparser.go index 9fa37d41..b8adb8ae 100644 --- a/core/utils/dateparser.go +++ b/core/utils/dateparser.go @@ -1,13 +1,9 @@ package utils import ( - "fmt" - "strings" "time" "github.com/sdslabs/beastv4/core/config" - - "github.com/araddon/dateparse" ) func CheckTime() (error, int) { @@ -17,31 +13,11 @@ func CheckTime() (error, int) { return err, -1 } - loc, _ := time.LoadLocation(strings.Split(competitionInfo.TimeZone, ":")[0]) - time.Local = loc - currentTime := time.Now().In(loc) - - compStartTime := strings.Split(competitionInfo.StartingTime, ",") - compStartDate := strings.Split(compStartTime[1][1:], " ") - startDate := fmt.Sprintf("%s %s, %s", compStartDate[1], compStartDate[0], compStartDate[2]) - startTime := strings.Split(compStartTime[0], " ")[0] - startTime = fmt.Sprintf("%s, %s", startDate, startTime) - - st, err := dateparse.ParseLocal(startTime) - if err != nil { - return err, -1 - } - - compEndTime := strings.Split(competitionInfo.EndingTime, ",") - compEndDate := strings.Split(compEndTime[1][1:], " ") - endDate := fmt.Sprintf("%s %s, %s", compEndDate[1], compEndDate[0], compEndDate[2]) - endTime := strings.Split(compEndTime[0], " ")[0] - endTime = fmt.Sprintf("%s, %s", endDate, endTime) - - et, err := dateparse.ParseLocal(endTime) + st, et, err := competitionInfo.ParseWindow() if err != nil { return err, -1 } + currentTime := time.Now().In(st.Location()) if currentTime.Before(st) { return nil, 0 diff --git a/core/utils/dateparser_test.go b/core/utils/dateparser_test.go new file mode 100644 index 00000000..ee922b9c --- /dev/null +++ b/core/utils/dateparser_test.go @@ -0,0 +1,21 @@ +package utils + +import ( + "testing" + + "github.com/sdslabs/beastv4/core/config" +) + +func TestCheckTimeRejectsMalformedWindowWithoutPanicking(t *testing.T) { + previous := config.Cfg + config.Cfg = &config.BeastConfig{CompetitionInfo: config.CompetitionInfo{ + StartingTime: "malformed", + EndingTime: "also malformed", + TimeZone: "UTC", + }} + t.Cleanup(func() { config.Cfg = previous }) + + if err, state := CheckTime(); err == nil || state != -1 { + t.Fatalf("expected malformed time error and state -1, got %v and %d", err, state) + } +} From ab5f8293a30d7b543823ab09c6cafeb6fbb1e864 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 13:05:05 +0530 Subject: [PATCH 067/149] fix(scoring): use validated competition window Signed-off-by: vibhatsu --- core/database/challenges.go | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/core/database/challenges.go b/core/database/challenges.go index b9bfd133..3475acea 100644 --- a/core/database/challenges.go +++ b/core/database/challenges.go @@ -7,8 +7,6 @@ import ( "strings" "time" - "github.com/araddon/dateparse" - "github.com/sdslabs/beastv4/core" "github.com/sdslabs/beastv4/core/config" log "github.com/sirupsen/logrus" @@ -705,24 +703,16 @@ func QueryUserAttempts(user_id uint) ([]ChallengeAttempt, error) { // timeElapsed returns the aggregation bucket for the given duration func timeElapsed() TimeAggBucket { - startStr := config.Cfg.CompetitionInfo.StartingTime - // The format is "16:31:23 UTC: +05:30, 03 February 2025, Monday" - // We'll parse only the part up to the date, ignoring the weekday - // e.g. "16:31:23 UTC: +05:30, 03 February 2025" - parts := strings.Split(startStr, ",") - if len(parts) < 2 { - log.Errorf("invalid StartingTime format: %s", startStr) + if config.Cfg == nil { + log.Error("cannot aggregate scores before config initialization") return Between1MonthAnd1Year } - startTimePart := strings.TrimSpace(parts[0]) - startDatePart := strings.TrimSpace(parts[1]) - startParseStr := startTimePart + ", " + startDatePart - start, err := dateparse.ParseLocal(startParseStr) + start, _, err := config.Cfg.CompetitionInfo.ParseWindow() if err != nil { - log.Errorf("failed to parse StartingTime: %v", err) + log.Errorf("parse competition window for score aggregation: %v", err) return Between1MonthAnd1Year } - end := time.Now() + end := time.Now().In(start.Location()) diff := end.Sub(start) minutes := diff.Minutes() hours := diff.Hours() From b36deb2d2f976efab563154a58dd17f88a9331a6 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 13:06:00 +0530 Subject: [PATCH 068/149] fix(auth): generate OTPs cryptographically Signed-off-by: vibhatsu --- api/otp.go | 24 ++++++++++++++++++------ api/otp_test.go | 16 ++++++++++++++++ 2 files changed, 34 insertions(+), 6 deletions(-) create mode 100644 api/otp_test.go diff --git a/api/otp.go b/api/otp.go index cb0f80f7..ff75aa09 100644 --- a/api/otp.go +++ b/api/otp.go @@ -2,12 +2,13 @@ package api import ( "bytes" + "crypto/rand" "crypto/tls" "errors" "fmt" "html/template" "log" - "math/rand" + "math/big" "net/http" "net/smtp" "os" @@ -24,9 +25,12 @@ import ( "gorm.io/gorm" ) -func generateOTP() string { - r := rand.New(rand.NewSource(time.Now().UnixNano())) - return fmt.Sprintf("%06d", r.Intn(1000000)) // 6-digit OTP +func generateOTP() (string, error) { + value, err := rand.Int(rand.Reader, big.NewInt(1000000)) + if err != nil { + return "", err + } + return fmt.Sprintf("%06d", value.Int64()), nil } // sendEmail sends an OTP email using an SMTP client with TLS. Falls back to plain text if template is missing. @@ -169,7 +173,11 @@ func sendOTPHandler(c *gin.Context) { return } - otp := generateOTP() + otp, err := generateOTP() + if err != nil { + c.JSON(http.StatusInternalServerError, HTTPErrorResp{Error: "Failed to generate OTP"}) + return + } expiry := time.Now().Add(5 * time.Minute) // OTP expires in 5 minutes otpEntry, err := database.QueryOTPEntry(email) @@ -306,7 +314,11 @@ func sendOTPForForgetHandler(c *gin.Context) { return } - otp := generateOTP() + otp, err := generateOTP() + if err != nil { + c.JSON(http.StatusInternalServerError, HTTPErrorResp{Error: "Failed to generate OTP"}) + return + } expiry := time.Now().Add(5 * time.Minute) // OTP expires in 5 minutes otpEntry, err := database.QueryOTPEntry(email) diff --git a/api/otp_test.go b/api/otp_test.go new file mode 100644 index 00000000..c6e3669a --- /dev/null +++ b/api/otp_test.go @@ -0,0 +1,16 @@ +package api + +import ( + "regexp" + "testing" +) + +func TestGenerateOTPProducesSixDigits(t *testing.T) { + otp, err := generateOTP() + if err != nil { + t.Fatal(err) + } + if !regexp.MustCompile(`^[0-9]{6}$`).MatchString(otp) { + t.Fatalf("unexpected OTP format %q", otp) + } +} From df72175c9066b6307e3fdadaca564633a97a2642 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 13:07:22 +0530 Subject: [PATCH 069/149] fix(mail): verify SMTP TLS connections Signed-off-by: vibhatsu --- api/otp.go | 56 ++++++++++++++++++++++++++++++++++++++----------- api/otp_test.go | 11 ++++++++++ 2 files changed, 55 insertions(+), 12 deletions(-) diff --git a/api/otp.go b/api/otp.go index ff75aa09..62b14881 100644 --- a/api/otp.go +++ b/api/otp.go @@ -9,7 +9,9 @@ import ( "html/template" "log" "math/big" + "net" "net/http" + "net/mail" "net/smtp" "os" "path/filepath" @@ -39,6 +41,14 @@ func sendEmail(email, otp string) error { password := config.Cfg.MailConfig.Password smtpHost := config.Cfg.MailConfig.SMTPHost smtpPort := config.Cfg.MailConfig.SMTPPort + fromAddress, err := canonicalMailbox(from) + if err != nil { + return fmt.Errorf("invalid SMTP sender: %w", err) + } + recipientAddress, err := canonicalMailbox(email) + if err != nil { + return fmt.Errorf("invalid OTP recipient: %w", err) + } // Email subject subject := "Your OTP Code" @@ -53,7 +63,8 @@ func sendEmail(email, otp string) error { // Check if template file exists var body bytes.Buffer - _, err := os.Stat(emailTemplatePath) + htmlBody := false + _, err = os.Stat(emailTemplatePath) if err == nil { // Template exists, parse and execute tmpl, err := template.ParseFiles(emailTemplatePath) @@ -70,6 +81,7 @@ func sendEmail(email, otp string) error { log.Println("Failed to execute email template:", err) return err } + htmlBody = true } else { // Template does not exist, send plain text email log.Println("Template not found, sending plain text email.") @@ -77,13 +89,13 @@ func sendEmail(email, otp string) error { } // Create email headers - message := fmt.Sprintf("From: %s\r\n", from) + - fmt.Sprintf("To: %s\r\n", email) + + message := fmt.Sprintf("From: %s\r\n", fromAddress) + + fmt.Sprintf("To: %s\r\n", recipientAddress) + fmt.Sprintf("Subject: %s\r\n", subject) + "MIME-Version: 1.0\r\n" // Set Content-Type based on template availability - if body.String()[0] == '<' { + if htmlBody { message += "Content-Type: text/html; charset=\"utf-8\"\r\n\r\n" } else { message += "Content-Type: text/plain; charset=\"utf-8\"\r\n\r\n" @@ -93,38 +105,48 @@ func sendEmail(email, otp string) error { // Setup TLS connection tlsConfig := &tls.Config{ - InsecureSkipVerify: true, // Set true only if SMTP server uses self-signed certs - ServerName: smtpHost, + MinVersion: tls.VersionTLS12, + ServerName: smtpHost, } - // Connect to SMTP server - conn, err := tls.Dial("tcp", smtpHost+":"+smtpPort, tlsConfig) + dialer := &net.Dialer{Timeout: 10 * time.Second} + rawConn, err := dialer.Dial("tcp", net.JoinHostPort(smtpHost, smtpPort)) if err != nil { log.Println("Failed to connect to SMTP server:", err) return err } + conn := tls.Client(rawConn, tlsConfig) + if err := conn.SetDeadline(time.Now().Add(30 * time.Second)); err != nil { + _ = conn.Close() + return err + } + if err := conn.Handshake(); err != nil { + _ = conn.Close() + return fmt.Errorf("verify SMTP TLS connection: %w", err) + } client, err := smtp.NewClient(conn, smtpHost) if err != nil { + _ = conn.Close() log.Println("Failed to create SMTP client:", err) return err } defer client.Close() // Authenticate - auth := smtp.PlainAuth("", from, password, smtpHost) + auth := smtp.PlainAuth("", fromAddress, password, smtpHost) if err := client.Auth(auth); err != nil { log.Println("SMTP authentication failed:", err) return err } // Set sender and recipient - if err := client.Mail(from); err != nil { + if err := client.Mail(fromAddress); err != nil { log.Println("Failed to set sender:", err) return err } - if err := client.Rcpt(email); err != nil { + if err := client.Rcpt(recipientAddress); err != nil { log.Println("Failed to set recipient:", err) return err } @@ -154,10 +176,20 @@ func sendEmail(email, otp string) error { return err } - fmt.Println("OTP email sent successfully to", email) return nil } +func canonicalMailbox(value string) (string, error) { + if strings.ContainsAny(value, "\r\n") { + return "", errors.New("mailbox contains a line break") + } + parsed, err := mail.ParseAddress(value) + if err != nil || parsed.Address != value { + return "", fmt.Errorf("mailbox must be a bare email address") + } + return parsed.Address, nil +} + func sendOTPHandler(c *gin.Context) { email := c.PostForm("email") email = strings.TrimSpace(strings.ToLower(email)) diff --git a/api/otp_test.go b/api/otp_test.go index c6e3669a..7178209f 100644 --- a/api/otp_test.go +++ b/api/otp_test.go @@ -14,3 +14,14 @@ func TestGenerateOTPProducesSixDigits(t *testing.T) { t.Fatalf("unexpected OTP format %q", otp) } } + +func TestCanonicalMailboxRejectsHeaderInjection(t *testing.T) { + for _, value := range []string{"Name ", "user@example.com\r\nBcc: victim@example.com", "not-an-email"} { + if _, err := canonicalMailbox(value); err == nil { + t.Fatalf("expected mailbox %q to be rejected", value) + } + } + if got, err := canonicalMailbox("user@example.com"); err != nil || got != "user@example.com" { + t.Fatalf("expected canonical mailbox, got %q, %v", got, err) + } +} From 3fcdbe84f501b76affdb68ee0916a2e8a02565dc Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 13:08:16 +0530 Subject: [PATCH 070/149] feat(auth): add bounded one-time OTP grants Signed-off-by: vibhatsu --- core/database/otp.go | 91 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/core/database/otp.go b/core/database/otp.go index 0218813a..47077e6e 100644 --- a/core/database/otp.go +++ b/core/database/otp.go @@ -1,7 +1,24 @@ package database import ( + "crypto/subtle" + "errors" "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +var ( + ErrOTPRateLimited = errors.New("OTP was requested too recently") + ErrOTPInvalid = errors.New("invalid OTP") + ErrOTPExpired = errors.New("OTP expired") + ErrOTPAttempts = errors.New("too many OTP attempts") +) + +const ( + otpResendCooldown = time.Minute + maxOTPAttempts = 5 ) type OTP struct { @@ -9,6 +26,80 @@ type OTP struct { Code string Expiry time.Time Verified bool + Purpose string + CodeHash []byte + Attempts uint + SentAt time.Time +} + +func IssueOTP(email, purpose string, codeHash []byte, now, expiry time.Time) error { + DBMux.Lock() + defer DBMux.Unlock() + + return Db.Transaction(func(tx *gorm.DB) error { + var entry OTP + err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("email = ?", email).First(&entry).Error + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + if err == nil && !entry.SentAt.IsZero() && now.Before(entry.SentAt.Add(otpResendCooldown)) { + return ErrOTPRateLimited + } + + entry.Email = email + entry.Purpose = purpose + entry.CodeHash = append([]byte(nil), codeHash...) + entry.Expiry = expiry + entry.SentAt = now + entry.Attempts = 0 + entry.Verified = false + if errors.Is(err, gorm.ErrRecordNotFound) { + return tx.Create(&entry).Error + } + return tx.Save(&entry).Error + }) +} + +func VerifyOTPCode(email, purpose string, codeHash []byte, now time.Time) error { + DBMux.Lock() + defer DBMux.Unlock() + + var result error + err := Db.Transaction(func(tx *gorm.DB) error { + var entry OTP + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("email = ?", email).First(&entry).Error; err != nil { + return err + } + switch { + case entry.Verified || entry.Purpose != purpose || len(entry.CodeHash) == 0: + result = ErrOTPInvalid + case now.After(entry.Expiry): + result = ErrOTPExpired + case entry.Attempts >= maxOTPAttempts: + result = ErrOTPAttempts + case subtle.ConstantTimeCompare(entry.CodeHash, codeHash) != 1: + entry.Attempts++ + result = ErrOTPInvalid + return tx.Model(&entry).Update("attempts", entry.Attempts).Error + default: + result = nil + return tx.Model(&entry).Updates(map[string]interface{}{ + "verified": true, + "code_hash": []byte(nil), + }).Error + } + return nil + }) + if err != nil { + return err + } + return result +} + +func DeleteOTPEntry(email string) error { + DBMux.Lock() + defer DBMux.Unlock() + return Db.Delete(&OTP{}, "email = ?", email).Error } func CreateOTPEntry(otpEntry *OTP) error { From cacc02e3833febb623c612bf4969821999190c6b Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 13:10:02 +0530 Subject: [PATCH 071/149] fix(auth): scope password reset tokens Signed-off-by: vibhatsu --- api/auth.go | 25 ++++++++++++++-- api/otp.go | 15 +--------- api/router.go | 2 +- pkg/auth/token.go | 65 +++++++++++++++++++++++++++++++++++------- pkg/auth/token_test.go | 18 ++++++++++++ 5 files changed, 97 insertions(+), 28 deletions(-) diff --git a/api/auth.go b/api/auth.go index eb489b71..a8242a5d 100644 --- a/api/auth.go +++ b/api/auth.go @@ -10,7 +10,6 @@ import ( "github.com/sdslabs/beastv4/core" "github.com/sdslabs/beastv4/core/config" "github.com/sdslabs/beastv4/core/database" - coreUtils "github.com/sdslabs/beastv4/core/utils" "github.com/sdslabs/beastv4/pkg/auth" "gorm.io/gorm" ) @@ -73,6 +72,24 @@ func adminAuthorize(c *gin.Context) { authorizeRoles(c, core.ADMIN) } +func resetPasswordAuthorize(c *gin.Context) { + values := strings.Fields(c.GetHeader("Authorization")) + if len(values) != 2 || values[0] != "Bearer" { + c.AbortWithStatusJSON(http.StatusUnauthorized, HTTPPlainResp{Message: "No Token Provided"}) + return + } + claims, err := auth.AuthorizeClaims(values[1], core.MANAGER|core.ADMIN|core.USER) + if err != nil { + claims, err = auth.AuthorizePasswordResetClaims(values[1]) + } + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, HTTPPlainResp{Message: "Invalid reset token"}) + return + } + c.Set("authClaims", claims) + c.Next() +} + // Handles route related to receive JWT token // @Summary Handles signin and token production // @Description JWT can be received by signing in @@ -232,13 +249,15 @@ func resetPasswordHandler(c *gin.Context) { newPass := c.PostForm("new_pass") newPass = strings.TrimSpace(newPass) - username, err := coreUtils.GetUser(c.GetHeader("Authorization")) - if err != nil { + claimsValue, exists := c.Get("authClaims") + claims, ok := claimsValue.(*auth.CustomClaims) + if !exists || !ok { c.JSON(http.StatusUnauthorized, HTTPPlainResp{ Message: "Unauthorized user", }) return } + username := claims.User user, err := database.QueryFirstUserEntry("username", username) if err != nil { diff --git a/api/otp.go b/api/otp.go index 62b14881..17b035d6 100644 --- a/api/otp.go +++ b/api/otp.go @@ -19,7 +19,6 @@ import ( "time" "github.com/gin-gonic/gin" - jwt "github.com/golang-jwt/jwt/v4" "github.com/sdslabs/beastv4/core" "github.com/sdslabs/beastv4/core/config" "github.com/sdslabs/beastv4/core/database" @@ -456,19 +455,7 @@ func verifyOTPForForgetHandler(c *gin.Context) { return } - now := time.Now() - - token := jwt.NewWithClaims(jwt.SigningMethodHS256, auth.CustomClaims{ - User: userEntry.Username, - Role: userEntry.Role, - RegisteredClaims: jwt.RegisteredClaims{ - ExpiresAt: jwt.NewNumericDate(now.Add(5 * time.Minute)), - IssuedAt: jwt.NewNumericDate(now), - Issuer: auth.ISSUER, - }, - }) - - tempToken, err := token.SignedString([]byte(auth.JWTSECRET)) + tempToken, err := auth.GeneratePasswordResetJWT(userEntry.Username, userEntry.Role, 5*time.Minute) if err != nil { c.JSON(http.StatusInternalServerError, HTTPErrorResp{ diff --git a/api/router.go b/api/router.go index df0f4c1a..54f16834 100644 --- a/api/router.go +++ b/api/router.go @@ -34,7 +34,7 @@ func initGinRouter() *gin.Engine { { authGroup.POST("/register", register) authGroup.POST("/login", login) - authGroup.POST("/reset-password", authorize, resetPasswordHandler) + authGroup.POST("/reset-password", resetPasswordAuthorize, resetPasswordHandler) authGroup.POST("/send-otp", sendOTPHandler) authGroup.POST("/verify-otp", verifyOTPHandler) authGroup.POST("/send-otp-forget", sendOTPForForgetHandler) diff --git a/pkg/auth/token.go b/pkg/auth/token.go index ff19cdef..09f3245d 100644 --- a/pkg/auth/token.go +++ b/pkg/auth/token.go @@ -8,11 +8,17 @@ import ( ) type CustomClaims struct { - User string `json:"usr"` - Role string `json:"role"` + User string `json:"usr"` + Role string `json:"role"` + TokenUse string `json:"token_use"` jwt.RegisteredClaims } +const ( + AccessTokenUse = "access" + PasswordResetTokenUse = "password_reset" +) + const ( ADMIN int = 1 << 0 MANAGER int = 1 << 1 @@ -26,6 +32,35 @@ var ( ) func AuthorizeClaims(jwtTokenString string, roleAccess int) (*CustomClaims, error) { + claims, err := parseClaims(jwtTokenString) + if err != nil { + return nil, err + } + if claims.TokenUse != AccessTokenUse { + return nil, fmt.Errorf("token is not an access token") + } + + if !((roleAccess&MANAGER) != 0 && contains(ManagerRoles, claims.Role) || + (roleAccess&ADMIN) != 0 && contains(AdminRoles, claims.Role) || + (roleAccess&USER) != 0 && contains(UserRoles, claims.Role)) { + return nil, fmt.Errorf("role access error") + } + + return claims, nil +} + +func AuthorizePasswordResetClaims(jwtTokenString string) (*CustomClaims, error) { + claims, err := parseClaims(jwtTokenString) + if err != nil { + return nil, err + } + if claims.TokenUse != PasswordResetTokenUse { + return nil, fmt.Errorf("token is not a password reset token") + } + return claims, nil +} + +func parseClaims(jwtTokenString string) (*CustomClaims, error) { token, err := jwt.ParseWithClaims(jwtTokenString, &CustomClaims{}, func(token *jwt.Token) (interface{}, error) { if token.Method != jwt.SigningMethodHS256 { return nil, fmt.Errorf("token signing method is invalid") @@ -45,12 +80,6 @@ func AuthorizeClaims(jwtTokenString string, roleAccess int) (*CustomClaims, erro return nil, fmt.Errorf("token issuer is invalid") } - if !((roleAccess&MANAGER) != 0 && contains(ManagerRoles, claims.Role) || - (roleAccess&ADMIN) != 0 && contains(AdminRoles, claims.Role) || - (roleAccess&USER) != 0 && contains(UserRoles, claims.Role)) { - return nil, fmt.Errorf("role access error") - } - return claims, nil } @@ -63,8 +92,9 @@ func GenerateJWT(authEntry AuthModel) (string, error) { now := time.Now() token := jwt.NewWithClaims(jwt.SigningMethodHS256, CustomClaims{ - User: authEntry.Username, - Role: authEntry.Role, + User: authEntry.Username, + Role: authEntry.Role, + TokenUse: AccessTokenUse, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(now.Add(time.Duration(TIME_PERIOD) * time.Second)), IssuedAt: jwt.NewNumericDate(now), @@ -75,6 +105,21 @@ func GenerateJWT(authEntry AuthModel) (string, error) { return token.SignedString([]byte(JWTSECRET)) } +func GeneratePasswordResetJWT(username, role string, lifetime time.Duration) (string, error) { + now := time.Now() + token := jwt.NewWithClaims(jwt.SigningMethodHS256, CustomClaims{ + User: username, + Role: role, + TokenUse: PasswordResetTokenUse, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(now.Add(lifetime)), + IssuedAt: jwt.NewNumericDate(now), + Issuer: ISSUER, + }, + }) + return token.SignedString([]byte(JWTSECRET)) +} + func contains(a []string, x string) bool { for _, n := range a { if x == n { diff --git a/pkg/auth/token_test.go b/pkg/auth/token_test.go index a6739207..78b08e7c 100644 --- a/pkg/auth/token_test.go +++ b/pkg/auth/token_test.go @@ -82,3 +82,21 @@ func TestAuthorizeClaimsRejectsExpiredToken(t *testing.T) { t.Fatal("expected expiration rejection") } } + +func TestPasswordResetTokenCannotAuthorizeAPIRequests(t *testing.T) { + initializeTokenTest() + token, err := GeneratePasswordResetJWT("alice", "admin", time.Minute) + if err != nil { + t.Fatal(err) + } + if _, err := AuthorizeClaims(token, ADMIN); err == nil { + t.Fatal("password reset token authorized an API request") + } + claims, err := AuthorizePasswordResetClaims(token) + if err != nil { + t.Fatal(err) + } + if claims.User != "alice" { + t.Fatalf("unexpected reset subject %q", claims.User) + } +} From 28c8175a6563b294ddc8be09628998d9fe43509d Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 13:10:59 +0530 Subject: [PATCH 072/149] feat(auth): consume verified OTP grants Signed-off-by: vibhatsu --- core/database/otp.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/core/database/otp.go b/core/database/otp.go index 47077e6e..c7d4328b 100644 --- a/core/database/otp.go +++ b/core/database/otp.go @@ -102,6 +102,22 @@ func DeleteOTPEntry(email string) error { return Db.Delete(&OTP{}, "email = ?", email).Error } +func ConsumeVerifiedOTP(email, purpose string, now time.Time) error { + DBMux.Lock() + defer DBMux.Unlock() + + return Db.Transaction(func(tx *gorm.DB) error { + var entry OTP + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("email = ?", email).First(&entry).Error; err != nil { + return err + } + if !entry.Verified || entry.Purpose != purpose || now.After(entry.Expiry) { + return ErrOTPInvalid + } + return tx.Delete(&entry).Error + }) +} + func CreateOTPEntry(otpEntry *OTP) error { DBMux.Lock() defer DBMux.Unlock() From f7ec1aed751ca46449529926ec6002d3ecad4ef2 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 13:16:53 +0530 Subject: [PATCH 073/149] fix(auth): enforce one-time OTP workflows Signed-off-by: vibhatsu --- api/auth.go | 61 +++++++--- api/auth_test.go | 19 +++ api/otp.go | 295 +++++++++++++---------------------------------- api/otp_test.go | 16 +++ 4 files changed, 165 insertions(+), 226 deletions(-) create mode 100644 api/auth_test.go diff --git a/api/auth.go b/api/auth.go index a8242a5d..2e202b73 100644 --- a/api/auth.go +++ b/api/auth.go @@ -4,7 +4,9 @@ import ( "errors" "log" "net/http" + "regexp" "strings" + "time" "github.com/gin-gonic/gin" "github.com/sdslabs/beastv4/core" @@ -14,6 +16,18 @@ import ( "gorm.io/gorm" ) +var contestantUsernamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_.-]{2,11}$`) + +func validatePassword(password string) error { + if len(password) < 12 || len(password) > 128 { + return errors.New("password must contain between 12 and 128 bytes") + } + if strings.TrimSpace(password) == "" { + return errors.New("password cannot contain only whitespace") + } + return nil +} + // Acts as a middleware to authorize user // @Summary Handles authorization of user // @Description Authorizes user by checking if JWT token exists and is valid @@ -108,12 +122,12 @@ func login(c *gin.Context) { password := c.PostForm("password") username = strings.TrimSpace(strings.ToLower(username)) - password = strings.TrimSpace(password) if username == "" || password == "" { c.JSON(http.StatusBadRequest, HTTPPlainResp{ Message: "Username and password can not be empty", }) + return } userEntry, err := database.QueryFirstUserEntry("username", username) @@ -170,7 +184,6 @@ func register(c *gin.Context) { name = strings.TrimSpace(name) username = strings.TrimSpace(strings.ToLower(username)) - password = strings.TrimSpace(password) email = strings.TrimSpace(strings.ToLower(email)) if username == "" || password == "" || email == "" { @@ -181,22 +194,19 @@ func register(c *gin.Context) { return } - if len(username) > 12 { + if !contestantUsernamePattern.MatchString(username) { c.JSON(http.StatusBadRequest, HTTPErrorResp{ - Error: "Username cannot be greater than 12 characters", + Error: "Username must be 3-12 lowercase letters, digits, dots, underscores, or hyphens", }) return } - - authModel, err := auth.CreateModel(username, password, core.USER_ROLES["contestant"]) - if err != nil { - c.JSON(http.StatusInternalServerError, HTTPErrorResp{Error: "Failed to secure user credentials"}) + if err := validatePassword(password); err != nil { + c.JSON(http.StatusBadRequest, HTTPErrorResp{Error: err.Error()}) return } - userEntry := database.User{ - Name: name, - AuthModel: authModel, - Email: email, + if canonical, err := canonicalMailbox(email); err != nil || canonical != email { + c.JSON(http.StatusBadRequest, HTTPErrorResp{Error: "A valid email address is required"}) + return } smtpHost := config.Cfg.MailConfig.SMTPHost @@ -215,10 +225,20 @@ func register(c *gin.Context) { } return } - if !otpEntry.Verified { + if !otpEntry.Verified || otpEntry.Purpose != otpPurposeRegistration || time.Now().After(otpEntry.Expiry) { c.JSON(http.StatusNotAcceptable, HTTPErrorResp{Error: "Email not verified, cannot register user"}) return } + authModel, err := auth.CreateModel(username, password, core.USER_ROLES["contestant"]) + if err != nil { + c.JSON(http.StatusInternalServerError, HTTPErrorResp{Error: "Failed to secure user credentials"}) + return + } + userEntry := database.User{ + Name: name, + AuthModel: authModel, + Email: email, + } err = database.CreateUserEntry(&userEntry) if err != nil { @@ -227,6 +247,9 @@ func register(c *gin.Context) { }) return } + if err := database.DeleteOTPEntry(email); err != nil { + log.Printf("Failed to consume registration OTP: %v", err) + } markLeaderboardCachesStale() @@ -247,7 +270,10 @@ func register(c *gin.Context) { // @Router /auth/reset-password [post] func resetPasswordHandler(c *gin.Context) { newPass := c.PostForm("new_pass") - newPass = strings.TrimSpace(newPass) + if err := validatePassword(newPass); err != nil { + c.JSON(http.StatusBadRequest, HTTPPlainResp{Message: err.Error()}) + return + } claimsValue, exists := c.Get("authClaims") claims, ok := claimsValue.(*auth.CustomClaims) @@ -264,6 +290,7 @@ func resetPasswordHandler(c *gin.Context) { c.JSON(http.StatusUnauthorized, HTTPPlainResp{ Message: "Unauthorized user", }) + return } authModel, err := auth.CreateModel(username, newPass, user.Role) @@ -271,6 +298,12 @@ func resetPasswordHandler(c *gin.Context) { c.JSON(http.StatusInternalServerError, HTTPPlainResp{Message: "Failed to secure user credentials"}) return } + if claims.TokenUse == auth.PasswordResetTokenUse { + if err := database.ConsumeVerifiedOTP(user.Email, otpPurposePasswordReset, time.Now()); err != nil { + c.JSON(http.StatusUnauthorized, HTTPPlainResp{Message: "Password reset grant is invalid or already used"}) + return + } + } err = database.UpdateUser(&user, map[string]interface{}{"Password": authModel.Password, "Salt": authModel.Salt}) if err != nil { diff --git a/api/auth_test.go b/api/auth_test.go new file mode 100644 index 00000000..c952e33c --- /dev/null +++ b/api/auth_test.go @@ -0,0 +1,19 @@ +package api + +import "testing" + +func TestCredentialValidation(t *testing.T) { + for _, password := range []string{"short", " ", string(make([]byte, 129))} { + if err := validatePassword(password); err == nil { + t.Fatalf("expected password %q to be rejected", password) + } + } + if err := validatePassword("correct horse battery staple"); err != nil { + t.Fatal(err) + } + for _, username := range []string{"ab", "UPPER", "../escape", "space name"} { + if contestantUsernamePattern.MatchString(username) { + t.Fatalf("expected username %q to be rejected", username) + } + } +} diff --git a/api/otp.go b/api/otp.go index 17b035d6..a45f92f9 100644 --- a/api/otp.go +++ b/api/otp.go @@ -2,7 +2,9 @@ package api import ( "bytes" + "crypto/hmac" "crypto/rand" + "crypto/sha256" "crypto/tls" "errors" "fmt" @@ -26,6 +28,12 @@ import ( "gorm.io/gorm" ) +const ( + otpPurposeRegistration = "registration" + otpPurposePasswordReset = "password_reset" + otpLifetime = 5 * time.Minute +) + func generateOTP() (string, error) { value, err := rand.Int(rand.Reader, big.NewInt(1000000)) if err != nil { @@ -189,255 +197,118 @@ func canonicalMailbox(value string) (string, error) { return parsed.Address, nil } -func sendOTPHandler(c *gin.Context) { - email := c.PostForm("email") - email = strings.TrimSpace(strings.ToLower(email)) - - smtpHost := config.Cfg.MailConfig.SMTPHost - smtpPort := config.Cfg.MailConfig.SMTPPort - - if smtpHost == "" || smtpPort == "" { - log.Printf("WARNING: %s", "SMTP not configured") - c.JSON(http.StatusInternalServerError, HTTPErrorResp{ - Error: "SMTP not configured", - }) - return - } +func otpCodeHash(email, purpose, code string) []byte { + mac := hmac.New(sha256.New, []byte(config.Cfg.JWTSecret)) + _, _ = mac.Write([]byte(purpose + "\x00" + email + "\x00" + code)) + return mac.Sum(nil) +} - otp, err := generateOTP() +func requestedOTPEmail(c *gin.Context) (string, error) { + email := strings.TrimSpace(strings.ToLower(c.PostForm("email"))) + canonical, err := canonicalMailbox(email) if err != nil { - c.JSON(http.StatusInternalServerError, HTTPErrorResp{Error: "Failed to generate OTP"}) - return + return "", err } - expiry := time.Now().Add(5 * time.Minute) // OTP expires in 5 minutes - - otpEntry, err := database.QueryOTPEntry(email) + return canonical, nil +} +func issueOTP(c *gin.Context, purpose string, existingUserRequired bool) { + email, err := requestedOTPEmail(c) if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - otpEntry = database.OTP{ - Email: email, - Code: otp, - Expiry: expiry, - } - } else { - log.Println("Failed to query OTP:", err) - c.JSON(http.StatusInternalServerError, HTTPErrorResp{ - Error: "Failed to send OTP", - }) - return - } - } - - if otpEntry.Verified { - c.JSON(http.StatusOK, HTTPPlainResp{ - Message: "Email already verified", - }) + c.JSON(http.StatusBadRequest, HTTPErrorResp{Error: "A valid email address is required"}) return } - - otpEntry.Code = otp - otpEntry.Expiry = expiry - - err = database.CreateOTPEntry(&otpEntry) - if err != nil { - log.Println("Failed to store OTP:", err) - c.JSON(http.StatusInternalServerError, HTTPErrorResp{ - Error: "Failed to store OTP", - }) + mailConfig := config.Cfg.MailConfig + if mailConfig.From == "" || mailConfig.Password == "" || mailConfig.SMTPHost == "" || mailConfig.SMTPPort == "" { + c.JSON(http.StatusServiceUnavailable, HTTPErrorResp{Error: "SMTP not configured"}) return } - - // Send OTP to email - err = sendEmail(email, otp) - if err != nil { - log.Println("Failed to send OTP:", err) - c.JSON(http.StatusInternalServerError, HTTPErrorResp{ - Error: "Failed to send OTP", - }) + eligible := true + _, userErr := database.QueryFirstUserEntry("email", email) + if userErr != nil && !errors.Is(userErr, gorm.ErrRecordNotFound) { + c.JSON(http.StatusInternalServerError, HTTPErrorResp{Error: "Failed to send OTP"}) return } - - c.JSON(http.StatusOK, HTTPPlainResp{ - Message: "OTP sent successfully", - }) -} - -func verifyOTPHandler(c *gin.Context) { - email := c.PostForm("email") - otp := strings.TrimSpace(c.PostForm("otp")) - email = strings.TrimSpace(strings.ToLower(email)) - - smtpHost := config.Cfg.MailConfig.SMTPHost - smtpPort := config.Cfg.MailConfig.SMTPPort - - if smtpHost == "" || smtpPort == "" { - log.Printf("WARNING: %s", "SMTP not configured") - c.JSON(http.StatusInternalServerError, HTTPErrorResp{ - Error: "SMTP not configured", - }) + if existingUserRequired { + eligible = userErr == nil + } else if userErr == nil { + c.JSON(http.StatusOK, HTTPPlainResp{Message: "If the request is eligible, an OTP has been sent"}) return } - - otpEntry, err := database.QueryOTPEntry(email) - + code, err := generateOTP() if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - c.JSON(http.StatusUnauthorized, HTTPErrorResp{ - Error: "OTP not found", - }) - return - } else { - log.Println("Failed to query OTP:", err) - c.JSON(http.StatusInternalServerError, HTTPErrorResp{ - Error: "Failed to send OTP", - }) - return - } - } - - if otpEntry.Verified { - c.JSON(http.StatusOK, HTTPPlainResp{ - Message: "Email already verified", - }) + c.JSON(http.StatusInternalServerError, HTTPErrorResp{Error: "Failed to generate OTP"}) return } - - if otpEntry.Code != otp { - c.JSON(http.StatusUnauthorized, HTTPErrorResp{ - Error: "Invalid OTP", - }) + now := time.Now() + if err := database.IssueOTP(email, purpose, otpCodeHash(email, purpose, code), now, now.Add(otpLifetime)); err != nil { + if errors.Is(err, database.ErrOTPRateLimited) { + c.Header("Retry-After", "60") + c.JSON(http.StatusTooManyRequests, HTTPErrorResp{Error: "OTP requested too recently"}) + return + } + log.Println("Failed to store OTP:", err) + c.JSON(http.StatusInternalServerError, HTTPErrorResp{Error: "Failed to store OTP"}) return } - - if time.Now().After(otpEntry.Expiry) { - c.JSON(http.StatusUnauthorized, HTTPErrorResp{ - Error: "OTP expired", - }) + if !eligible { + c.JSON(http.StatusOK, HTTPPlainResp{Message: "If the request is eligible, an OTP has been sent"}) return } - err = database.VerifyOTPEntry(email) - - if err != nil { - c.JSON(http.StatusInternalServerError, HTTPErrorResp{ - Error: "Failed to verify OTP", - }) + if err := sendEmail(email, code); err != nil { + _ = database.DeleteOTPEntry(email) + log.Println("Failed to send OTP:", err) + c.JSON(http.StatusBadGateway, HTTPErrorResp{Error: "Failed to send OTP"}) return } - - c.JSON(http.StatusOK, HTTPPlainResp{ - Message: "OTP verified successfully", - }) + c.JSON(http.StatusOK, HTTPPlainResp{Message: "If the request is eligible, an OTP has been sent"}) } -func sendOTPForForgetHandler(c *gin.Context) { - email := c.PostForm("email") - email = strings.TrimSpace(strings.ToLower(email)) - - smtpHost := config.Cfg.MailConfig.SMTPHost - smtpPort := config.Cfg.MailConfig.SMTPPort - - if smtpHost == "" || smtpPort == "" { - log.Printf("WARNING: %s", "SMTP not configured") - c.JSON(http.StatusInternalServerError, HTTPErrorResp{ - Error: "SMTP not configured", - }) - return - } - - otp, err := generateOTP() +func verifyRequestedOTP(c *gin.Context, purpose string) (string, bool) { + email, err := requestedOTPEmail(c) if err != nil { - c.JSON(http.StatusInternalServerError, HTTPErrorResp{Error: "Failed to generate OTP"}) - return + c.JSON(http.StatusBadRequest, HTTPErrorResp{Error: "A valid email address is required"}) + return "", false } - expiry := time.Now().Add(5 * time.Minute) // OTP expires in 5 minutes - - otpEntry, err := database.QueryOTPEntry(email) - + code := strings.TrimSpace(c.PostForm("otp")) + if len(code) != 6 || strings.IndexFunc(code, func(r rune) bool { return r < '0' || r > '9' }) >= 0 { + c.JSON(http.StatusUnauthorized, HTTPErrorResp{Error: "Invalid OTP"}) + return "", false + } + err = database.VerifyOTPCode(email, purpose, otpCodeHash(email, purpose, code), time.Now()) if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - otpEntry = database.OTP{ - Email: email, - Code: otp, - Expiry: expiry, - } - } else { - log.Println("Failed to query OTP:", err) - c.JSON(http.StatusInternalServerError, HTTPErrorResp{ - Error: "Failed to send OTP", - }) - return + switch { + case errors.Is(err, database.ErrOTPAttempts): + c.JSON(http.StatusTooManyRequests, HTTPErrorResp{Error: "Too many OTP attempts"}) + case errors.Is(err, database.ErrOTPInvalid), errors.Is(err, database.ErrOTPExpired), errors.Is(err, gorm.ErrRecordNotFound): + c.JSON(http.StatusUnauthorized, HTTPErrorResp{Error: "Invalid or expired OTP"}) + default: + log.Println("Failed to verify OTP:", err) + c.JSON(http.StatusInternalServerError, HTTPErrorResp{Error: "Failed to verify OTP"}) } + return "", false } + return email, true +} - otpEntry.Code = otp - otpEntry.Expiry = expiry - - err = database.CreateOTPEntry(&otpEntry) - if err != nil { - log.Println("Failed to store OTP:", err) - c.JSON(http.StatusInternalServerError, HTTPErrorResp{ - Error: "Failed to store OTP", - }) - return - } +func sendOTPHandler(c *gin.Context) { + issueOTP(c, otpPurposeRegistration, false) +} - // Send OTP to email - err = sendEmail(email, otp) - if err != nil { - log.Println("Failed to send OTP:", err) - c.JSON(http.StatusInternalServerError, HTTPErrorResp{ - Error: "Failed to send OTP", - }) +func verifyOTPHandler(c *gin.Context) { + if _, ok := verifyRequestedOTP(c, otpPurposeRegistration); !ok { return } + c.JSON(http.StatusOK, HTTPPlainResp{Message: "OTP verified successfully"}) +} - c.JSON(http.StatusOK, HTTPPlainResp{ - Message: "OTP sent successfully", - }) +func sendOTPForForgetHandler(c *gin.Context) { + issueOTP(c, otpPurposePasswordReset, true) } func verifyOTPForForgetHandler(c *gin.Context) { - email := c.PostForm("email") - otp := strings.TrimSpace(c.PostForm("otp")) - email = strings.TrimSpace(strings.ToLower(email)) - smtpHost := config.Cfg.MailConfig.SMTPHost - smtpPort := config.Cfg.MailConfig.SMTPPort - - if smtpHost == "" || smtpPort == "" { - log.Printf("WARNING: %s", "SMTP not configured") - c.JSON(http.StatusInternalServerError, HTTPErrorResp{ - Error: "SMTP not configured", - }) - return - } - - otpEntry, err := database.QueryOTPEntry(email) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - c.JSON(http.StatusUnauthorized, HTTPErrorResp{ - Error: "OTP not found", - }) - } else { - log.Println("Failed to query OTP:", err) - c.JSON(http.StatusInternalServerError, HTTPErrorResp{ - Error: "Failed to verify OTP", - }) - } - return - } - - if otpEntry.Code != otp { - c.JSON(http.StatusUnauthorized, HTTPErrorResp{ - Error: "Invalid OTP", - }) - return - } - - if time.Now().After(otpEntry.Expiry) { - c.JSON(http.StatusUnauthorized, HTTPErrorResp{ - Error: "OTP expired", - }) + email, ok := verifyRequestedOTP(c, otpPurposePasswordReset) + if !ok { return } userEntry, err := database.QueryFirstUserEntry("email", email) diff --git a/api/otp_test.go b/api/otp_test.go index 7178209f..8dc645be 100644 --- a/api/otp_test.go +++ b/api/otp_test.go @@ -1,8 +1,11 @@ package api import ( + "bytes" "regexp" "testing" + + "github.com/sdslabs/beastv4/core/config" ) func TestGenerateOTPProducesSixDigits(t *testing.T) { @@ -15,6 +18,19 @@ func TestGenerateOTPProducesSixDigits(t *testing.T) { } } +func TestOTPHashBindsPurposeAndEmail(t *testing.T) { + previous := config.Cfg + config.Cfg = &config.BeastConfig{JWTSecret: "01234567890123456789012345678901"} + t.Cleanup(func() { config.Cfg = previous }) + + registration := otpCodeHash("user@example.com", otpPurposeRegistration, "123456") + reset := otpCodeHash("user@example.com", otpPurposePasswordReset, "123456") + otherUser := otpCodeHash("other@example.com", otpPurposeRegistration, "123456") + if bytes.Equal(registration, reset) || bytes.Equal(registration, otherUser) { + t.Fatal("OTP hash was not bound to purpose and email") + } +} + func TestCanonicalMailboxRejectsHeaderInjection(t *testing.T) { for _, value := range []string{"Name ", "user@example.com\r\nBcc: victim@example.com", "not-an-email"} { if _, err := canonicalMailbox(value); err == nil { From 06a595d3efa731346be4ef085dc02c1b6fbdf10b Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 13:18:06 +0530 Subject: [PATCH 074/149] fix(auth): clear legacy plaintext OTPs Signed-off-by: vibhatsu --- core/database/database.go | 3 +++ core/database/otp.go | 30 +++++++----------------------- 2 files changed, 10 insertions(+), 23 deletions(-) diff --git a/core/database/database.go b/core/database/database.go index 04b724bd..bb033184 100644 --- a/core/database/database.go +++ b/core/database/database.go @@ -106,6 +106,9 @@ func Init() error { if err := MigrateChallengeMaintainers(); err != nil { return fmt.Errorf("migrate challenge maintainers: %w", err) } + if err := ClearLegacyOTPSecrets(); err != nil { + return fmt.Errorf("clear legacy OTP secrets: %w", err) + } users, err := QueryUserEntries("email", core.DEFAULT_USER_EMAIL) if err != nil { diff --git a/core/database/otp.go b/core/database/otp.go index c7d4328b..2a42fa7a 100644 --- a/core/database/otp.go +++ b/core/database/otp.go @@ -23,7 +23,6 @@ const ( type OTP struct { Email string `gorm:"primaryKey"` - Code string Expiry time.Time Verified bool Purpose string @@ -32,6 +31,13 @@ type OTP struct { SentAt time.Time } +func ClearLegacyOTPSecrets() error { + if !Db.Migrator().HasColumn(&OTP{}, "code") { + return nil + } + return Db.Exec("UPDATE otps SET code = ''").Error +} + func IssueOTP(email, purpose string, codeHash []byte, now, expiry time.Time) error { DBMux.Lock() defer DBMux.Unlock() @@ -118,21 +124,6 @@ func ConsumeVerifiedOTP(email, purpose string, now time.Time) error { }) } -func CreateOTPEntry(otpEntry *OTP) error { - DBMux.Lock() - defer DBMux.Unlock() - - var existingOTP OTP - tx := Db.First(&existingOTP, "email = ?", otpEntry.Email) - if tx.Error == nil { - existingOTP.Code = otpEntry.Code - existingOTP.Expiry = otpEntry.Expiry - return Db.Save(&existingOTP).Error - } - - return Db.Create(otpEntry).Error -} - func QueryOTPEntry(email string) (OTP, error) { var otpEntry OTP @@ -143,10 +134,3 @@ func QueryOTPEntry(email string) (OTP, error) { return otpEntry, tx.Error } - -func VerifyOTPEntry(email string) error { - DBMux.Lock() - defer DBMux.Unlock() - - return Db.Model(&OTP{}).Where("email = ?", email).Update("verified", true).Error -} From 38657d028a9b782e600c62b2cf885617c202f654 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 13:19:57 +0530 Subject: [PATCH 075/149] fix(auth): rate limit login attempts Signed-off-by: vibhatsu --- api/auth.go | 29 ++++++++++++-------- api/rate_limit.go | 60 ++++++++++++++++++++++++++++++++++++++++++ api/rate_limit_test.go | 16 +++++++++++ 3 files changed, 94 insertions(+), 11 deletions(-) create mode 100644 api/rate_limit.go create mode 100644 api/rate_limit_test.go diff --git a/api/auth.go b/api/auth.go index 2e202b73..b843e150 100644 --- a/api/auth.go +++ b/api/auth.go @@ -129,20 +129,21 @@ func login(c *gin.Context) { }) return } - - userEntry, err := database.QueryFirstUserEntry("username", username) - - if err != nil { - c.JSON(http.StatusBadRequest, HTTPPlainResp{ - Message: err.Error(), - }) + if !enforceLoginRateLimit(c, username) { return } - if userEntry.Status == 1 { - c.JSON(http.StatusForbidden, HTTPPlainResp{ - Message: "The user has been banned from this competition. Please contact competition admin for more information", - }) + userEntry, err := database.QueryFirstUserEntry("username", username) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + _, _ = auth.Authenticate(username, password, auth.AuthModel{ + Salt: make([]byte, 16), + Password: make([]byte, core.HASH_LENGTH), + }) + c.JSON(http.StatusUnauthorized, HTTPPlainResp{Message: "The username or password is invalid"}) + return + } + c.JSON(http.StatusServiceUnavailable, HTTPPlainResp{Message: "Authentication service unavailable"}) return } @@ -154,6 +155,12 @@ func login(c *gin.Context) { }) return } + if userEntry.Status == 1 { + c.JSON(http.StatusForbidden, HTTPPlainResp{ + Message: "The user has been banned from this competition. Please contact competition admin for more information", + }) + return + } c.JSON(http.StatusOK, HTTPAuthorizeResp{ Token: jwt, diff --git a/api/rate_limit.go b/api/rate_limit.go new file mode 100644 index 00000000..e164507b --- /dev/null +++ b/api/rate_limit.go @@ -0,0 +1,60 @@ +package api + +import ( + "context" + "crypto/sha256" + "fmt" + "net" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/sdslabs/beastv4/core/cache" +) + +const loginRateWindow = 5 * time.Minute + +const incrementWithExpiryScript = ` +local count = redis.call("INCR", KEYS[1]) +if count == 1 then + redis.call("PEXPIRE", KEYS[1], ARGV[1]) +end +return count +` + +func rateLimitKey(kind, value string) string { + digest := sha256.Sum256([]byte(kind + "\x00" + value)) + return fmt.Sprintf("beast:rate:%s:%x", kind, digest) +} + +func incrementRateLimit(ctx context.Context, key string, window time.Duration) (int64, error) { + if cache.Cache == nil { + return 0, fmt.Errorf("cache is unavailable") + } + return cache.Cache.Eval(ctx, incrementWithExpiryScript, []string{key}, window.Milliseconds()).Int64() +} + +func enforceLoginRateLimit(c *gin.Context, username string) bool { + host, _, err := net.SplitHostPort(c.Request.RemoteAddr) + if err != nil { + host = c.Request.RemoteAddr + } + ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second) + defer cancel() + + usernameCount, err := incrementRateLimit(ctx, rateLimitKey("login-user", username), loginRateWindow) + if err == nil { + var addressCount int64 + addressCount, err = incrementRateLimit(ctx, rateLimitKey("login-address", host), loginRateWindow) + if err == nil && usernameCount <= 10 && addressCount <= 50 { + return true + } + } + if err != nil { + c.AbortWithStatusJSON(http.StatusServiceUnavailable, HTTPErrorResp{Error: "Authentication service unavailable"}) + return false + } + c.Header("Retry-After", "300") + c.AbortWithStatusJSON(http.StatusTooManyRequests, HTTPErrorResp{Error: "Too many login attempts"}) + return false +} diff --git a/api/rate_limit_test.go b/api/rate_limit_test.go new file mode 100644 index 00000000..c3960b59 --- /dev/null +++ b/api/rate_limit_test.go @@ -0,0 +1,16 @@ +package api + +import ( + "strings" + "testing" +) + +func TestRateLimitKeyDoesNotExposeIdentity(t *testing.T) { + key := rateLimitKey("login-user", "sensitive-user") + if strings.Contains(key, "sensitive-user") { + t.Fatalf("rate limit key leaks identity: %q", key) + } + if key != rateLimitKey("login-user", "sensitive-user") { + t.Fatal("rate limit key is not deterministic") + } +} From 7fd6e3f7ed736be11391b9c8a3c8396379774810 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 13:20:22 +0530 Subject: [PATCH 076/149] fix(auth): rate limit OTP delivery Signed-off-by: vibhatsu --- api/otp.go | 3 +++ api/rate_limit.go | 29 +++++++++++++++++++++++++---- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/api/otp.go b/api/otp.go index a45f92f9..ddd8963b 100644 --- a/api/otp.go +++ b/api/otp.go @@ -213,6 +213,9 @@ func requestedOTPEmail(c *gin.Context) (string, error) { } func issueOTP(c *gin.Context, purpose string, existingUserRequired bool) { + if !enforceOTPSendRateLimit(c) { + return + } email, err := requestedOTPEmail(c) if err != nil { c.JSON(http.StatusBadRequest, HTTPErrorResp{Error: "A valid email address is required"}) diff --git a/api/rate_limit.go b/api/rate_limit.go index e164507b..0eeb643c 100644 --- a/api/rate_limit.go +++ b/api/rate_limit.go @@ -35,10 +35,7 @@ func incrementRateLimit(ctx context.Context, key string, window time.Duration) ( } func enforceLoginRateLimit(c *gin.Context, username string) bool { - host, _, err := net.SplitHostPort(c.Request.RemoteAddr) - if err != nil { - host = c.Request.RemoteAddr - } + host := requestPeerHost(c) ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second) defer cancel() @@ -58,3 +55,27 @@ func enforceLoginRateLimit(c *gin.Context, username string) bool { c.AbortWithStatusJSON(http.StatusTooManyRequests, HTTPErrorResp{Error: "Too many login attempts"}) return false } + +func enforceOTPSendRateLimit(c *gin.Context) bool { + ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second) + defer cancel() + count, err := incrementRateLimit(ctx, rateLimitKey("otp-address", requestPeerHost(c)), 10*time.Minute) + if err != nil { + c.AbortWithStatusJSON(http.StatusServiceUnavailable, HTTPErrorResp{Error: "OTP service unavailable"}) + return false + } + if count > 10 { + c.Header("Retry-After", "600") + c.AbortWithStatusJSON(http.StatusTooManyRequests, HTTPErrorResp{Error: "Too many OTP requests"}) + return false + } + return true +} + +func requestPeerHost(c *gin.Context) string { + host, _, err := net.SplitHostPort(c.Request.RemoteAddr) + if err != nil { + return c.Request.RemoteAddr + } + return host +} From a545b3801c089a5818f28daf1c39944a5a14f7dc Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 13:21:59 +0530 Subject: [PATCH 077/149] fix(api): restrict cross-origin access Signed-off-by: vibhatsu --- _examples/example.config.toml | 1 + api/router.go | 17 ++++++++++------- core/config/config.go | 28 ++++++++++++++++++++++++++-- core/config/utils_test.go | 12 ++++++++++++ 4 files changed, 49 insertions(+), 9 deletions(-) diff --git a/_examples/example.config.toml b/_examples/example.config.toml index 14c49425..140f5b98 100644 --- a/_examples/example.config.toml +++ b/_examples/example.config.toml @@ -124,6 +124,7 @@ max_instances_per_user = 3 [server] tls_cert_file = "$HOME/.beast/secrets/tls.crt" tls_key_file = "$HOME/.beast/secrets/tls.key" +allowed_origins = [] # The following fields are required only while hosting a competition on beast # This section contains information about the competition to be hosted diff --git a/api/router.go b/api/router.go index 54f16834..aadf9f86 100644 --- a/api/router.go +++ b/api/router.go @@ -9,6 +9,7 @@ import ( "github.com/gin-contrib/static" "github.com/gin-gonic/gin" "github.com/sdslabs/beastv4/core" + "github.com/sdslabs/beastv4/core/config" ) func dummyHandler(c *gin.Context) { @@ -20,14 +21,16 @@ func dummyHandler(c *gin.Context) { func initGinRouter() *gin.Engine { router := gin.New() - corsConfig := cors.Config{ - AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE"}, - AllowHeaders: []string{"Origin", "Content-Length", "Content-Type", "Authorization", "Cookie"}, - AllowCredentials: false, - AllowAllOrigins: true, - MaxAge: 12 * time.Hour, + if len(config.Cfg.ServerConfig.AllowedOrigins) > 0 { + corsConfig := cors.Config{ + AllowOrigins: config.Cfg.ServerConfig.AllowedOrigins, + AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE"}, + AllowHeaders: []string{"Origin", "Content-Length", "Content-Type", "Authorization"}, + AllowCredentials: false, + MaxAge: 12 * time.Hour, + } + router.Use(cors.New(corsConfig)) } - router.Use(cors.New(corsConfig)) router.GET("/dummy", dummyHandler) // Authorization routes group authGroup := router.Group("/auth") diff --git a/core/config/config.go b/core/config/config.go index 9bc9c0a7..0b68fd04 100644 --- a/core/config/config.go +++ b/core/config/config.go @@ -139,11 +139,15 @@ type InstanceConfig struct { } type ServerConfig struct { - TLSCertFile string `toml:"tls_cert_file"` - TLSKeyFile string `toml:"tls_key_file"` + TLSCertFile string `toml:"tls_cert_file"` + TLSKeyFile string `toml:"tls_key_file"` + AllowedOrigins []string `toml:"allowed_origins"` } func (config *ServerConfig) Validate() error { + if err := validateAllowedOrigins(config.AllowedOrigins); err != nil { + return err + } if config.TLSCertFile == "" || config.TLSKeyFile == "" { return errors.New("server tls_cert_file and tls_key_file are required") } @@ -168,6 +172,26 @@ func (config *ServerConfig) Validate() error { return nil } +func validateAllowedOrigins(origins []string) error { + seenOrigins := make(map[string]struct{}, len(origins)) + for _, origin := range origins { + parsed, err := url.ParseRequestURI(origin) + if err != nil || parsed.Host == "" || parsed.User != nil || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" { + return fmt.Errorf("invalid CORS origin %q", origin) + } + hostname := parsed.Hostname() + loopback := hostname == "localhost" || net.ParseIP(hostname) != nil && net.ParseIP(hostname).IsLoopback() + if parsed.Scheme != "https" && !(parsed.Scheme == "http" && loopback) { + return fmt.Errorf("CORS origin must use HTTPS unless it is loopback: %q", origin) + } + if _, exists := seenOrigins[origin]; exists { + return fmt.Errorf("duplicate CORS origin %q", origin) + } + seenOrigins[origin] = struct{}{} + } + return nil +} + func (config *InstanceConfig) Validate() { if config.DefaultExpiration <= 0 { config.DefaultExpiration = core.DEFAULT_MINIMUM_EXTEND_TIME diff --git a/core/config/utils_test.go b/core/config/utils_test.go index 1d37218a..b7fcefa2 100644 --- a/core/config/utils_test.go +++ b/core/config/utils_test.go @@ -347,3 +347,15 @@ func TestCompetitionInfoValidatesTimeWindow(t *testing.T) { t.Fatal("expected malformed competition time error") } } + +func TestServerRejectsUnsafeCORSOrigins(t *testing.T) { + if err := validateAllowedOrigins([]string{"*"}); err == nil { + t.Fatal("expected wildcard CORS origin error") + } + if err := validateAllowedOrigins([]string{"http://example.com"}); err == nil { + t.Fatal("expected insecure CORS origin error") + } + if err := validateAllowedOrigins([]string{"https://example.com", "http://localhost:3000"}); err != nil { + t.Fatal(err) + } +} From ecfb4b952e7cc8b2d811005a9cc0f34ce931cc23 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 13:22:51 +0530 Subject: [PATCH 078/149] fix(api): recover handler panics safely Signed-off-by: vibhatsu --- api/router.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/api/router.go b/api/router.go index aadf9f86..dcd866d6 100644 --- a/api/router.go +++ b/api/router.go @@ -1,6 +1,7 @@ package api import ( + "io" "net/http" "path/filepath" "time" @@ -10,6 +11,7 @@ import ( "github.com/gin-gonic/gin" "github.com/sdslabs/beastv4/core" "github.com/sdslabs/beastv4/core/config" + log "github.com/sirupsen/logrus" ) func dummyHandler(c *gin.Context) { @@ -20,6 +22,10 @@ func dummyHandler(c *gin.Context) { func initGinRouter() *gin.Engine { router := gin.New() + router.Use(gin.CustomRecoveryWithWriter(io.Discard, func(c *gin.Context, _ interface{}) { + log.Error("request handler panic recovered") + c.AbortWithStatusJSON(http.StatusInternalServerError, HTTPErrorResp{Error: "Internal server error"}) + })) if len(config.Cfg.ServerConfig.AllowedOrigins) > 0 { corsConfig := cors.Config{ From 47ae646dc2ebd57dc0aa4aa685b93826cd0cbcfd Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 13:23:31 +0530 Subject: [PATCH 079/149] fix(api): bound request body sizes Signed-off-by: vibhatsu --- api/request_limit_test.go | 24 ++++++++++++++++++++++++ api/router.go | 16 ++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 api/request_limit_test.go diff --git a/api/request_limit_test.go b/api/request_limit_test.go new file mode 100644 index 00000000..acc55672 --- /dev/null +++ b/api/request_limit_test.go @@ -0,0 +1,24 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestLimitRequestBodyRejectsOversizePayload(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(limitRequestBody) + router.POST("/test", func(c *gin.Context) { c.Status(http.StatusNoContent) }) + + request := httptest.NewRequest(http.MethodPost, "/test", strings.NewReader(strings.Repeat("x", int(maxAPIRequestBytes+1)))) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("status = %d, want %d", response.Code, http.StatusRequestEntityTooLarge) + } +} diff --git a/api/router.go b/api/router.go index dcd866d6..300cd3e3 100644 --- a/api/router.go +++ b/api/router.go @@ -20,6 +20,21 @@ func dummyHandler(c *gin.Context) { }) } +const maxAPIRequestBytes int64 = 2 << 20 + +func limitRequestBody(c *gin.Context) { + limit := maxAPIRequestBytes + if c.Request.URL.Path == "/api/manage/challenge/upload" { + limit = maxChallengeUploadRequestBytes + } + if c.Request.ContentLength > limit { + c.AbortWithStatusJSON(http.StatusRequestEntityTooLarge, HTTPErrorResp{Error: "Request body is too large"}) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, limit) + c.Next() +} + func initGinRouter() *gin.Engine { router := gin.New() router.Use(gin.CustomRecoveryWithWriter(io.Discard, func(c *gin.Context, _ interface{}) { @@ -37,6 +52,7 @@ func initGinRouter() *gin.Engine { } router.Use(cors.New(corsConfig)) } + router.Use(limitRequestBody) router.GET("/dummy", dummyHandler) // Authorization routes group authGroup := router.Group("/auth") From 9a84e273173a6b68ba403ab3e45f6bff4e036339 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 13:25:25 +0530 Subject: [PATCH 080/149] fix(assets): serve only declared challenge files Signed-off-by: vibhatsu --- api/assets_test.go | 19 +++++++++++++++++++ api/info.go | 43 +++++++++++++++++++++++++++++-------------- 2 files changed, 48 insertions(+), 14 deletions(-) create mode 100644 api/assets_test.go diff --git a/api/assets_test.go b/api/assets_test.go new file mode 100644 index 00000000..3e5ad75d --- /dev/null +++ b/api/assets_test.go @@ -0,0 +1,19 @@ +package api + +import ( + "testing" + + "github.com/sdslabs/beastv4/core" +) + +func TestDeclaresAssetRequiresExactMetadataEntry(t *testing.T) { + assets := "guide.pdf" + core.DELIMITER + "images/logo.png" + if !declaresAsset(assets, "images/logo.png") { + t.Fatal("declared nested asset was rejected") + } + for _, requested := range []string{"flag.txt", "../guide.pdf", "logo.png"} { + if declaresAsset(assets, requested) { + t.Fatalf("undeclared asset %q was accepted", requested) + } + } +} diff --git a/api/info.go b/api/info.go index 4f0c1f31..1b04ad2e 100644 --- a/api/info.go +++ b/api/info.go @@ -15,7 +15,6 @@ import ( cfg "github.com/sdslabs/beastv4/core/config" "github.com/sdslabs/beastv4/core/database" "github.com/sdslabs/beastv4/core/utils" - coreUtils "github.com/sdslabs/beastv4/core/utils" "github.com/sdslabs/beastv4/pkg/auth" fileUtils "github.com/sdslabs/beastv4/utils" log "github.com/sirupsen/logrus" @@ -58,7 +57,7 @@ func hintHandler(c *gin.Context) { return } - username, err := coreUtils.GetUser(c.GetHeader("Authorization")) + username, err := utils.GetUser(c.GetHeader("Authorization")) if err != nil { c.JSON(http.StatusUnauthorized, HTTPErrorResp{ Error: "Unauthorized user", @@ -189,7 +188,7 @@ func challengeInfoHandler(c *gin.Context) { } authHeader := c.GetHeader("Authorization") - username, err := coreUtils.GetUser(authHeader) + username, err := utils.GetUser(authHeader) if err != nil { c.JSON(http.StatusUnauthorized, HTTPErrorResp{ Error: "No Token Provided", @@ -378,7 +377,7 @@ func challengesMetadataHandler(c *gin.Context) { availableChallenges := make([]ChallengeMetadata, 0, len(challenges)) authHeader := c.GetHeader("Authorization") - username, err := coreUtils.GetUser(authHeader) + username, err := utils.GetUser(authHeader) if err != nil { c.JSON(http.StatusUnauthorized, HTTPErrorResp{ Error: "No Token Provided", @@ -919,22 +918,38 @@ func tagHandler(c *gin.Context) { // @Failure 500 {object} api.HTTPPlainResp // @Router /api/info/download [get] func serveAssets(c *gin.Context) { - challenge := c.Query("challenge") + challengeName := c.Query("challenge") assetName := c.Query("asset") - challenge = filepath.Base(challenge) - assetName = filepath.Base(assetName) - if challenge == "" || assetName == "" { + if challengeName == "" || challengeName != filepath.Base(challengeName) || strings.Contains(challengeName, `\`) || assetName == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "challenge and asset parameters are required"}) return } - filepath := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_STAGING_DIR, challenge, core.BEAST_STATIC_FOLDER, assetName) - err := fileUtils.ValidateFileExists(filepath) - if err != nil { + if err, state := utils.CheckTime(); err != nil || state == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "Incorrect file requested"}) + return + } + challenge, err := database.QueryFirstChallengeEntry("name", challengeName) + if err != nil || !declaresAsset(challenge.Assets, assetName) { + c.JSON(http.StatusBadRequest, gin.H{"error": "Incorrect file requested"}) + return + } + staticRoot := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_STAGING_DIR, challengeName, core.BEAST_STATIC_FOLDER) + assetPath, err := fileUtils.ResolvePathWithin(staticRoot, assetName) + if err != nil || fileUtils.ValidateFileExists(assetPath) != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Incorrect file requested"}) return } - c.FileAttachment(filepath, assetName) + c.FileAttachment(assetPath, filepath.Base(assetName)) + +} +func declaresAsset(encodedAssets, requested string) bool { + for _, asset := range strings.Split(encodedAssets, core.DELIMITER) { + if asset == requested { + return true + } + } + return false } // This route returns the number of users in the databse with role=contestant @@ -1258,7 +1273,7 @@ func unfreezeLeaderboardHandler(c *gin.Context) { // @Failure 500 {object} api.HTTPErrorResp // @Router /api/challenges/{challenge_id}/attempts [get] func getChallengeAttempts(c *gin.Context) { - username, err := coreUtils.GetUser(c.GetHeader("Authorization")) + username, err := utils.GetUser(c.GetHeader("Authorization")) if err != nil { c.JSON(http.StatusUnauthorized, HTTPErrorResp{ Error: "Unauthorized user", @@ -1353,7 +1368,7 @@ func getChallengeAttempts(c *gin.Context) { // @Failure 500 {object} api.HTTPErrorResp // @Router /api/info/submissions/user/{user_id} [get] func getUserAttempts(c *gin.Context) { - username, err := coreUtils.GetUser(c.GetHeader("Authorization")) + username, err := utils.GetUser(c.GetHeader("Authorization")) if err != nil { c.JSON(http.StatusUnauthorized, HTTPErrorResp{ Error: "Unauthorized user", From a1ee937483c143fbd8fecfbb4b9a4e433fe04002 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 13:26:59 +0530 Subject: [PATCH 081/149] fix(authz): scope challenge flag visibility Signed-off-by: vibhatsu --- api/challenge_access_test.go | 27 +++++++++++++++++++++++++++ api/info.go | 17 ++++++++++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 api/challenge_access_test.go diff --git a/api/challenge_access_test.go b/api/challenge_access_test.go new file mode 100644 index 00000000..01db9a65 --- /dev/null +++ b/api/challenge_access_test.go @@ -0,0 +1,27 @@ +package api + +import ( + "testing" + + "github.com/sdslabs/beastv4/core" + "github.com/sdslabs/beastv4/core/database" + "github.com/sdslabs/beastv4/pkg/auth" +) + +func TestCanViewChallengeSecretsForIntrinsicRoles(t *testing.T) { + challenge := database.Challenge{AuthorID: 7} + challenge.ID = 11 + admin := database.User{AuthModel: auth.AuthModel{Role: core.USER_ROLES["admin"]}} + if allowed, err := canViewChallengeSecrets(&admin, &challenge); err != nil || !allowed { + t.Fatalf("admin access = %v, %v", allowed, err) + } + owner := database.User{AuthModel: auth.AuthModel{Role: core.USER_ROLES["author"]}} + owner.ID = 7 + if allowed, err := canViewChallengeSecrets(&owner, &challenge); err != nil || !allowed { + t.Fatalf("owner access = %v, %v", allowed, err) + } + contestant := database.User{AuthModel: auth.AuthModel{Role: core.USER_ROLES["contestant"]}} + if allowed, err := canViewChallengeSecrets(&contestant, &challenge); err != nil || allowed { + t.Fatalf("contestant access = %v, %v", allowed, err) + } +} diff --git a/api/info.go b/api/info.go index 1b04ad2e..b56c65c8 100644 --- a/api/info.go +++ b/api/info.go @@ -265,7 +265,12 @@ func challengeInfoHandler(c *gin.Context) { MaxAttemptLimit: challenge.MaxAttemptLimit, DeployedLink: challenge.ServerDeployed, } - if user.Role == core.USER_ROLES["contestant"] { + canViewSecret, err := canViewChallengeSecrets(&user, &challenge) + if err != nil { + c.JSON(http.StatusInternalServerError, HTTPErrorResp{Error: "DATABASE ERROR while checking challenge access."}) + return + } + if !canViewSecret { c.JSON(http.StatusOK, challengeInfo) return } @@ -282,6 +287,16 @@ func challengeInfoHandler(c *gin.Context) { } } +func canViewChallengeSecrets(user *database.User, challenge *database.Challenge) (bool, error) { + if user.Role == core.USER_ROLES["admin"] || challenge.AuthorID == user.ID { + return true, nil + } + if user.Role == core.USER_ROLES["contestant"] { + return false, nil + } + return database.IsChallengeMaintainer(user.ID, challenge.ID) +} + // Returns metadata about all challenges with and without filters // @Summary Returns metadata about all challenges with and without filters. // @Description Returns information about all the challenges present in the database with and without filters. From a371c55f719cf984bf410c3f1cc27430b6b7b880 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 13:27:57 +0530 Subject: [PATCH 082/149] fix(authz): scope submission flag visibility Signed-off-by: vibhatsu --- api/info.go | 58 ++++++++++++++++++++++++++++++----------------------- 1 file changed, 33 insertions(+), 25 deletions(-) diff --git a/api/info.go b/api/info.go index b56c65c8..2c62f089 100644 --- a/api/info.go +++ b/api/info.go @@ -1304,8 +1304,6 @@ func getChallengeAttempts(c *gin.Context) { return } - isContestant := queryingUser.Role == core.USER_ROLES["contestant"] - challengeIDStr := c.Param("challenge_id") challengeID, err := strconv.ParseUint(challengeIDStr, 10, 64) if err != nil { @@ -1330,9 +1328,10 @@ func getChallengeAttempts(c *gin.Context) { return } - challengeTags := make([]string, len(challenge[0].Tags)) - for index, tag := range challenge[0].Tags { - challengeTags[index] = tag.TagName + canViewSecrets, err := canViewChallengeSecrets(&queryingUser, &challenge[0]) + if err != nil { + c.JSON(http.StatusInternalServerError, HTTPErrorResp{Error: "DATABASE ERROR while checking challenge access."}) + return } attempts, err := database.QueryChallAttempts(challengeID) @@ -1346,7 +1345,7 @@ func getChallengeAttempts(c *gin.Context) { resp := make([]SubmissionResp, 0, len(attempts)) for _, attempt := range attempts { - if isContestant && (!attempt.Correct || attempt.Cheating) { + if !canViewSecrets && (!attempt.Correct || attempt.Cheating) { continue } @@ -1359,7 +1358,7 @@ func getChallengeAttempts(c *gin.Context) { Success: attempt.Correct, } - if !isContestant { + if canViewSecrets { submissionResp.Flag = attempt.Flag submissionResp.Cheating = attempt.Cheating } @@ -1399,8 +1398,6 @@ func getUserAttempts(c *gin.Context) { return } - isContestant := user.Role == core.USER_ROLES["contestant"] - userIDStr := c.Param("user_id") userID, err := strconv.ParseUint(userIDStr, 10, 64) if err != nil { @@ -1435,36 +1432,47 @@ func getUserAttempts(c *gin.Context) { } resp := make([]SubmissionResp, 0, len(attempts)) + challengeCache := make(map[uint]database.Challenge) + accessCache := make(map[uint]bool) for _, attempt := range attempts { - if isContestant && (!attempt.Correct || attempt.Cheating) { - continue + challenge, exists := challengeCache[attempt.ChallengeID] + if !exists { + challenges, err := database.QueryChallengeEntries("id", strconv.FormatUint(uint64(attempt.ChallengeID), 10)) + if err != nil { + log.Errorf("DATABASE ERROR while fetching challenge details: %s", err.Error()) + continue + } + if len(challenges) == 0 { + continue + } + challenge = challenges[0] + challengeCache[attempt.ChallengeID] = challenge } - - challenge, err := database.QueryChallengeEntries("id", strconv.Itoa(int(attempt.ChallengeID))) - if err != nil { - log.Errorf("DATABASE ERROR while fetching challenge details: %s", err.Error()) - continue + canViewSecrets, exists := accessCache[attempt.ChallengeID] + if !exists { + var err error + canViewSecrets, err = canViewChallengeSecrets(&user, &challenge) + if err != nil { + c.JSON(http.StatusInternalServerError, HTTPErrorResp{Error: "DATABASE ERROR while checking challenge access."}) + return + } + accessCache[attempt.ChallengeID] = canViewSecrets } - if len(challenge) == 0 { + if !canViewSecrets && (!attempt.Correct || attempt.Cheating) { continue } - challengeTags := make([]string, len(challenge[0].Tags)) - for index, tag := range challenge[0].Tags { - challengeTags[index] = tag.TagName - } - submissionResp := SubmissionResp{ UserId: submissionUser.ID, Username: submissionUser.Username, - ChallId: challenge[0].ID, - ChallName: challenge[0].Name, + ChallId: challenge.ID, + ChallName: challenge.Name, SolvedAt: attempt.SolvedAt, Success: attempt.Correct, } - if !isContestant { + if canViewSecrets { submissionResp.Flag = attempt.Flag submissionResp.Cheating = attempt.Cheating } From 8536dfbc2c182cecc1154713b7242f7b1188b8a7 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 13:29:20 +0530 Subject: [PATCH 083/149] fix(database): report missing user records Signed-off-by: vibhatsu --- core/database/user.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/database/user.go b/core/database/user.go index 26c2e54a..f117dfad 100644 --- a/core/database/user.go +++ b/core/database/user.go @@ -104,7 +104,7 @@ func QueryFirstUserEntry(key string, value string) (User, error) { } if len(users) == 0 { - return User{}, nil + return User{}, gorm.ErrRecordNotFound } return users[0], nil From a773a6ba4ff473fe528177054f4df31b8097bff7 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 13:32:12 +0530 Subject: [PATCH 084/149] fix(containers): harden standard runtimes Signed-off-by: vibhatsu --- pkg/cr/container_security_test.go | 22 ++++++++++++++++++++ pkg/cr/containers.go | 34 ++++++++++++++++++++++--------- pkg/remoteManager/container.go | 15 ++++++++++++-- 3 files changed, 59 insertions(+), 12 deletions(-) create mode 100644 pkg/cr/container_security_test.go diff --git a/pkg/cr/container_security_test.go b/pkg/cr/container_security_test.go new file mode 100644 index 00000000..c75511be --- /dev/null +++ b/pkg/cr/container_security_test.go @@ -0,0 +1,22 @@ +package cr + +import ( + "testing" + + "github.com/docker/docker/api/types/container" +) + +func TestDefaultContainerSecurity(t *testing.T) { + hostConfig := &container.HostConfig{} + applyDefaultContainerSecurity(hostConfig) + if len(hostConfig.CapDrop) != 1 || hostConfig.CapDrop[0] != "ALL" { + t.Fatalf("unexpected dropped capabilities: %v", hostConfig.CapDrop) + } + if len(hostConfig.SecurityOpt) != 1 || hostConfig.SecurityOpt[0] != "no-new-privileges" { + t.Fatalf("unexpected security options: %v", hostConfig.SecurityOpt) + } + mounts := readOnlyBindMounts(map[string]string{"/host": "/container"}) + if len(mounts) != 1 || !mounts[0].ReadOnly { + t.Fatalf("bind mount was not read-only: %+v", mounts) + } +} diff --git a/pkg/cr/containers.go b/pkg/cr/containers.go index a36e9cb3..2a8caa9d 100644 --- a/pkg/cr/containers.go +++ b/pkg/cr/containers.go @@ -191,16 +191,7 @@ func CreateContainerFromImage(containerConfig *CreateContainerConfig) (string, e Labels: labels, } - var mountBindings []mount.Mount - for src, dest := range containerConfig.MountsMap { - mnt := mount.Mount{ - Type: mount.TypeBind, - Source: src, - Target: dest, - } - - mountBindings = append(mountBindings, mnt) - } + mountBindings := readOnlyBindMounts(containerConfig.MountsMap) resources := container.Resources{ NanoCPUs: int64(containerConfig.CPUsLimit * 1e9), @@ -215,6 +206,7 @@ func CreateContainerFromImage(containerConfig *CreateContainerConfig) (string, e NetworkMode: container.NetworkMode(containerConfig.ContainerNetwork), Resources: resources, } + applyDefaultContainerSecurity(hostConfig) createResp, err := cli.ContainerCreate(ctx, config, hostConfig, nil, nil, containerName) if err != nil { @@ -228,6 +220,10 @@ func CreateContainerFromImage(containerConfig *CreateContainerConfig) (string, e } if err := cli.ContainerStart(ctx, containerId, types.ContainerStartOptions{}); err != nil { + removeErr := cli.ContainerRemove(ctx, containerId, types.ContainerRemoveOptions{Force: true}) + if removeErr != nil { + log.Errorf("Error while removing failed container %s: %s", containerId, removeErr) + } log.Errorf("Error while starting the container : %s", err) return "", err } @@ -235,6 +231,24 @@ func CreateContainerFromImage(containerConfig *CreateContainerConfig) (string, e return containerId, nil } +func readOnlyBindMounts(mounts map[string]string) []mount.Mount { + bindings := make([]mount.Mount, 0, len(mounts)) + for src, dest := range mounts { + bindings = append(bindings, mount.Mount{ + Type: mount.TypeBind, + Source: src, + Target: dest, + ReadOnly: true, + }) + } + return bindings +} + +func applyDefaultContainerSecurity(hostConfig *container.HostConfig) { + hostConfig.CapDrop = []string{"ALL"} + hostConfig.SecurityOpt = []string{"no-new-privileges"} +} + func GetContainerStdLogs(containerID string) (*Log, error) { cli, err := newDockerClient() if err != nil { diff --git a/pkg/remoteManager/container.go b/pkg/remoteManager/container.go index 0ae8e953..1adee812 100644 --- a/pkg/remoteManager/container.go +++ b/pkg/remoteManager/container.go @@ -19,7 +19,7 @@ import ( ) func CreateContainerFromImageRemote(containerConfig cr.CreateContainerConfig, server config.AvailableServer) (string, error) { - arguments := []string{"docker", "run", "-d"} + arguments := []string{"docker", "run", "-d", "--cap-drop", "ALL", "--security-opt", "no-new-privileges=true"} if containerConfig.ContainerName != "" { arguments = append(arguments, "--name", containerConfig.ContainerName) } @@ -52,7 +52,18 @@ func CreateContainerFromImageRemote(containerConfig cr.CreateContainerConfig, se } sort.Strings(mountSources) for _, source := range mountSources { - arguments = append(arguments, "--mount", fmt.Sprintf("type=bind,source=%s,target=%s", source, containerConfig.MountsMap[source])) + resolvedSource := source + if !filepath.IsAbs(source) { + output, err := RunArgsOnServer(server, "realpath", source) + if err != nil { + return "", fmt.Errorf("resolve remote mount source %q: %w", source, err) + } + resolvedSource = strings.TrimSpace(output) + if !filepath.IsAbs(resolvedSource) { + return "", fmt.Errorf("remote mount source did not resolve to an absolute path: %q", source) + } + } + arguments = append(arguments, "--mount", fmt.Sprintf("type=bind,source=%s,target=%s,readonly", resolvedSource, containerConfig.MountsMap[source])) } arguments = append(arguments, containerConfig.ImageId) output, err := RunArgsOnServer(server, arguments...) From 2d84d12cc45b733e516d76a7e13f71d9162e3fd7 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 13:36:30 +0530 Subject: [PATCH 085/149] fix(compose): require hardened service defaults Signed-off-by: vibhatsu --- _examples/compose-type/docker-compose.yml | 6 +++ .../instanced-compose/docker-compose.yml | 6 +++ utils/compose.go | 45 +++++++++++++++++-- utils/compose_test.go | 31 +++++++++++-- 4 files changed, 80 insertions(+), 8 deletions(-) diff --git a/_examples/compose-type/docker-compose.yml b/_examples/compose-type/docker-compose.yml index c945a976..cfdd76b0 100644 --- a/_examples/compose-type/docker-compose.yml +++ b/_examples/compose-type/docker-compose.yml @@ -1,6 +1,9 @@ services: app: build: . + cap_drop: [ALL] + cap_add: [CHOWN, SETGID, SETUID] + security_opt: [no-new-privileges:true] ports: - "${APP_PORT}:80" environment: @@ -13,6 +16,9 @@ services: mysql: image: mysql:8 + cap_drop: [ALL] + cap_add: [CHOWN, DAC_OVERRIDE, SETGID, SETUID] + security_opt: [no-new-privileges:true] environment: MYSQL_DATABASE: my_db MYSQL_USER: my_user diff --git a/_examples/instanced-compose/docker-compose.yml b/_examples/instanced-compose/docker-compose.yml index 2f51b88c..fef6b569 100644 --- a/_examples/instanced-compose/docker-compose.yml +++ b/_examples/instanced-compose/docker-compose.yml @@ -5,6 +5,9 @@ services: build: context: . dockerfile: Dockerfile + cap_drop: [ALL] + cap_add: [CHOWN, SETGID, SETUID] + security_opt: [no-new-privileges:true] ports: - "${INSTANCE_PORT}:80" environment: @@ -18,6 +21,9 @@ services: db: image: mysql:5.7 + cap_drop: [ALL] + cap_add: [CHOWN, DAC_OVERRIDE, SETGID, SETUID] + security_opt: [no-new-privileges:true] environment: - MYSQL_ROOT_PASSWORD=rootpass - MYSQL_DATABASE=ctf diff --git a/utils/compose.go b/utils/compose.go index d442fac0..7fbcdbb9 100644 --- a/utils/compose.go +++ b/utils/compose.go @@ -25,6 +25,7 @@ type composeService struct { Volumes []interface{} `yaml:"volumes"` Devices []string `yaml:"devices"` CapAdd []string `yaml:"cap_add"` + CapDrop []string `yaml:"cap_drop"` SecurityOpt []string `yaml:"security_opt"` DeviceCgroupRules []string `yaml:"device_cgroup_rules"` Sysctls interface{} `yaml:"sysctls"` @@ -80,6 +81,15 @@ func validateComposeVolume(composeDir string, value interface{}) error { if filepath.IsAbs(source) || strings.HasPrefix(source, "~") { return fmt.Errorf("host volume source is not allowed: %s", source) } + readOnly := false + for _, option := range parts[2:] { + if option == "ro" || option == "readonly" { + readOnly = true + } + } + if !readOnly { + return fmt.Errorf("project bind mount %q must be read-only", source) + } if _, err := ResolvePathWithin(composeDir, source); err != nil { return fmt.Errorf("invalid project volume source %q: %w", source, err) } @@ -91,8 +101,9 @@ func validateComposeVolume(composeDir string, value interface{}) error { return fmt.Errorf("parse volume definition: %w", err) } var volume struct { - Type string `yaml:"type"` - Source string `yaml:"source"` + Type string `yaml:"type"` + Source string `yaml:"source"` + ReadOnly bool `yaml:"read_only"` } if err := yaml.Unmarshal(encoded, &volume); err != nil { return fmt.Errorf("parse volume definition: %w", err) @@ -103,6 +114,9 @@ func validateComposeVolume(composeDir string, value interface{}) error { if volume.Type != "bind" { return fmt.Errorf("unsupported volume type %q", volume.Type) } + if !volume.ReadOnly { + return fmt.Errorf("project bind mount %q must be read-only", volume.Source) + } if filepath.IsAbs(volume.Source) || strings.HasPrefix(volume.Source, "~") { return fmt.Errorf("host volume source is not allowed: %s", volume.Source) } @@ -214,9 +228,26 @@ func validateComposeSecurity(composeFile string, compose Compose) error { if service.Runtime != "" || service.CgroupParent != "" || service.ContainerName != "" { return fmt.Errorf("service %q overrides host-managed container settings", name) } - if len(service.Devices) > 0 || len(service.CapAdd) > 0 || len(service.DeviceCgroupRules) > 0 || service.Sysctls != nil { + if len(service.Devices) > 0 || len(service.DeviceCgroupRules) > 0 || service.Sysctls != nil { return fmt.Errorf("service %q requests additional host privileges", name) } + allowedCapabilities := map[string]struct{}{ + "CHOWN": {}, "DAC_OVERRIDE": {}, "FOWNER": {}, "SETGID": {}, "SETUID": {}, + } + for _, capability := range service.CapAdd { + if _, allowed := allowedCapabilities[strings.ToUpper(capability)]; !allowed { + return fmt.Errorf("service %q requests forbidden capability %q", name, capability) + } + } + dropsAllCapabilities := false + for _, capability := range service.CapDrop { + if strings.EqualFold(capability, "ALL") { + dropsAllCapabilities = true + } + } + if !dropsAllCapabilities { + return fmt.Errorf("service %q must set cap_drop to ALL", name) + } if service.Secrets != nil || service.Configs != nil { return fmt.Errorf("service %q uses unsupported secrets or configs", name) } @@ -229,11 +260,17 @@ func validateComposeSecurity(composeFile string, compose Compose) error { if err := validateComposeEnvFiles(composeDir, service.EnvFile); err != nil { return fmt.Errorf("service %q: %w", name, err) } + noNewPrivileges := false for _, option := range service.SecurityOpt { - if option != "no-new-privileges:true" { + if option == "no-new-privileges:true" { + noNewPrivileges = true + } else { return fmt.Errorf("service %q uses forbidden security_opt %q", name, option) } } + if !noNewPrivileges { + return fmt.Errorf("service %q must enable no-new-privileges", name) + } for _, volume := range service.Volumes { if err := validateComposeVolume(composeDir, volume); err != nil { return fmt.Errorf("service %q: %w", name, err) diff --git a/utils/compose_test.go b/utils/compose_test.go index c8b265cf..a3a793e5 100644 --- a/utils/compose_test.go +++ b/utils/compose_test.go @@ -26,7 +26,7 @@ func TestExtractPortsFromComposeRejectsHostEscapeSettings(t *testing.T) { {name: "host network", setting: " network_mode: host\n", want: "forbidden network_mode"}, {name: "host pid", setting: " pid: host\n", want: "host namespace"}, {name: "device", setting: " devices: [/dev/kvm:/dev/kvm]\n", want: "additional host privileges"}, - {name: "capability", setting: " cap_add: [SYS_ADMIN]\n", want: "additional host privileges"}, + {name: "capability", setting: " cap_add: [SYS_ADMIN]\n", want: "forbidden capability"}, {name: "runtime", setting: " runtime: runc\n", want: "host-managed container settings"}, {name: "socket", setting: " volumes: [/var/run/docker.sock:/var/run/docker.sock]\n", want: "host volume source"}, {name: "build escape", setting: " build: ..\n", want: "invalid build context"}, @@ -35,7 +35,7 @@ func TestExtractPortsFromComposeRejectsHostEscapeSettings(t *testing.T) { } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - path := writeCompose(t, "services:\n app:\n"+test.setting+" ports: [\"${APP_PORT}:80\"]\n") + path := writeCompose(t, "services:\n app:\n cap_drop: [ALL]\n security_opt: [no-new-privileges:true]\n"+test.setting+" ports: [\"${APP_PORT}:80\"]\n") _, err := ExtractPortsFromCompose(path) if err == nil || !strings.Contains(err.Error(), test.want) { t.Fatalf("expected %q error, got %v", test.want, err) @@ -50,7 +50,7 @@ func TestExtractPortsFromComposeAllowsProjectBindMount(t *testing.T) { t.Fatal(err) } path := filepath.Join(dir, "compose.yml") - content := "services:\n app:\n volumes: [\"./init.sql:/init.sql:ro\"]\n ports: [\"${APP_PORT}:80\"]\n" + content := "services:\n app:\n cap_drop: [ALL]\n security_opt: [no-new-privileges:true]\n volumes: [\"./init.sql:/init.sql:ro\"]\n ports: [\"${APP_PORT}:80\"]\n" if err := os.WriteFile(path, []byte(content), 0600); err != nil { t.Fatal(err) } @@ -74,7 +74,7 @@ func TestExtractPortsFromComposeRejectsEscapingBindMount(t *testing.T) { t.Fatal(err) } path := filepath.Join(dir, "compose.yml") - content := "services:\n app:\n volumes: [\"../secret:/secret:ro\"]\n ports: [\"${APP_PORT}:80\"]\n" + content := "services:\n app:\n cap_drop: [ALL]\n security_opt: [no-new-privileges:true]\n volumes: [\"../secret:/secret:ro\"]\n ports: [\"${APP_PORT}:80\"]\n" if err := os.WriteFile(path, []byte(content), 0600); err != nil { t.Fatal(err) } @@ -84,3 +84,26 @@ func TestExtractPortsFromComposeRejectsEscapingBindMount(t *testing.T) { t.Fatalf("expected escaping bind mount error, got %v", err) } } + +func TestExtractPortsFromComposeRequiresRuntimeHardening(t *testing.T) { + for name, setting := range map[string]string{ + "capabilities": " security_opt: [no-new-privileges:true]\n", + "privileges": " cap_drop: [ALL]\n", + "writable bind": " cap_drop: [ALL]\n security_opt: [no-new-privileges:true]\n" + + " volumes: [\"./asset:/asset\"]\n", + } { + t.Run(name, func(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "asset"), nil, 0600); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "compose.yml") + if err := os.WriteFile(path, []byte("services:\n app:\n"+setting+" ports: [\"${APP_PORT}:8080\"]\n"), 0600); err != nil { + t.Fatal(err) + } + if _, err := ExtractPortsFromCompose(path); err == nil { + t.Fatal("expected insecure Compose service to be rejected") + } + }) + } +} From 74d5ba8bea2ab14d09a40f91e0bea8f65dfd0518 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 13:40:46 +0530 Subject: [PATCH 086/149] fix(compose): enforce aggregate resource ceilings Signed-off-by: vibhatsu --- _examples/compose-type/docker-compose.yml | 6 ++ .../instanced-compose/docker-compose.yml | 6 ++ core/config/challenge.go | 9 +++ utils/compose.go | 57 +++++++++++++++++++ utils/compose_test.go | 10 ++++ 5 files changed, 88 insertions(+) diff --git a/_examples/compose-type/docker-compose.yml b/_examples/compose-type/docker-compose.yml index cfdd76b0..e99c3c67 100644 --- a/_examples/compose-type/docker-compose.yml +++ b/_examples/compose-type/docker-compose.yml @@ -4,6 +4,9 @@ services: cap_drop: [ALL] cap_add: [CHOWN, SETGID, SETUID] security_opt: [no-new-privileges:true] + mem_limit: 256m + cpus: 0.125 + pids_limit: 50 ports: - "${APP_PORT}:80" environment: @@ -19,6 +22,9 @@ services: cap_drop: [ALL] cap_add: [CHOWN, DAC_OVERRIDE, SETGID, SETUID] security_opt: [no-new-privileges:true] + mem_limit: 256m + cpus: 0.125 + pids_limit: 50 environment: MYSQL_DATABASE: my_db MYSQL_USER: my_user diff --git a/_examples/instanced-compose/docker-compose.yml b/_examples/instanced-compose/docker-compose.yml index fef6b569..684a0e63 100644 --- a/_examples/instanced-compose/docker-compose.yml +++ b/_examples/instanced-compose/docker-compose.yml @@ -8,6 +8,9 @@ services: cap_drop: [ALL] cap_add: [CHOWN, SETGID, SETUID] security_opt: [no-new-privileges:true] + mem_limit: 256m + cpus: 0.125 + pids_limit: 50 ports: - "${INSTANCE_PORT}:80" environment: @@ -24,6 +27,9 @@ services: cap_drop: [ALL] cap_add: [CHOWN, DAC_OVERRIDE, SETGID, SETUID] security_opt: [no-new-privileges:true] + mem_limit: 256m + cpus: 0.125 + pids_limit: 50 environment: - MYSQL_ROOT_PASSWORD=rootpass - MYSQL_DATABASE=ctf diff --git a/core/config/challenge.go b/core/config/challenge.go index 5e2746a5..6be918a2 100644 --- a/core/config/challenge.go +++ b/core/config/challenge.go @@ -78,6 +78,15 @@ func (config *BeastChallengeConfig) ValidateRequiredFields(challdir string) erro if err = config.Resources.ValidateRequiredFields(); err != nil { return err } + if config.Challenge.Env.DockerCompose != "" { + composePath, err := utils.ResolvePathWithin(challdir, config.Challenge.Env.DockerCompose) + if err != nil { + return err + } + if err := utils.ValidateComposeResources(composePath, config.Resources.Memory, config.Resources.PidsLimit, config.Resources.CPUsLimit); err != nil { + return fmt.Errorf("validate Compose resources: %w", err) + } + } for _, maintainer := range config.Maintainers { err = maintainer.ValidateRequiredFields() diff --git a/utils/compose.go b/utils/compose.go index 7fbcdbb9..a7c9344e 100644 --- a/utils/compose.go +++ b/utils/compose.go @@ -2,11 +2,14 @@ package utils import ( "fmt" + "math" "os" "path/filepath" "regexp" + "strconv" "strings" + "github.com/docker/go-units" "gopkg.in/yaml.v2" ) @@ -37,6 +40,60 @@ type composeService struct { VolumesFrom []string `yaml:"volumes_from"` Links []string `yaml:"links"` ExternalLinks []string `yaml:"external_links"` + MemoryLimit interface{} `yaml:"mem_limit"` + CPUsLimit interface{} `yaml:"cpus"` + PidsLimit int64 `yaml:"pids_limit"` +} + +func ValidateComposeResources(composeFile string, maxMemory, maxPids int64, maxCPUs float32) error { + data, err := os.ReadFile(composeFile) + if err != nil { + return err + } + var compose Compose + if err := yaml.Unmarshal(data, &compose); err != nil { + return err + } + var totalMemory, totalPids int64 + var totalCPUs float64 + for name, service := range compose.Services { + memory, err := parseComposeMemory(service.MemoryLimit) + if err != nil || memory <= 0 { + return fmt.Errorf("service %q must set a valid positive mem_limit", name) + } + cpus, err := strconv.ParseFloat(fmt.Sprint(service.CPUsLimit), 64) + if err != nil || cpus <= 0 || math.IsInf(cpus, 0) || math.IsNaN(cpus) { + return fmt.Errorf("service %q must set a valid positive cpus limit", name) + } + if service.PidsLimit <= 0 { + return fmt.Errorf("service %q must set a positive pids_limit", name) + } + if memory > maxMemory-totalMemory || service.PidsLimit > maxPids-totalPids || cpus > float64(maxCPUs)-totalCPUs+1e-9 { + return fmt.Errorf("Compose services exceed challenge resource limits") + } + totalMemory += memory + totalPids += service.PidsLimit + totalCPUs += cpus + } + return nil +} + +func parseComposeMemory(value interface{}) (int64, error) { + switch typed := value.(type) { + case int: + return int64(typed), nil + case int64: + return typed, nil + case uint64: + if typed > math.MaxInt64 { + return 0, fmt.Errorf("memory limit overflows int64") + } + return int64(typed), nil + case string: + return units.RAMInBytes(typed) + default: + return 0, fmt.Errorf("unsupported memory limit %v", value) + } } type composeResource struct { diff --git a/utils/compose_test.go b/utils/compose_test.go index a3a793e5..a3c5703e 100644 --- a/utils/compose_test.go +++ b/utils/compose_test.go @@ -107,3 +107,13 @@ func TestExtractPortsFromComposeRequiresRuntimeHardening(t *testing.T) { }) } } + +func TestValidateComposeResourcesEnforcesAggregateLimit(t *testing.T) { + path := writeCompose(t, "services:\n app:\n mem_limit: 256m\n cpus: 0.25\n pids_limit: 50\n db:\n mem_limit: 256m\n cpus: 0.25\n pids_limit: 50\n") + if err := ValidateComposeResources(path, 512<<20, 100, 0.5); err != nil { + t.Fatal(err) + } + if err := ValidateComposeResources(path, 256<<20, 100, 0.5); err == nil { + t.Fatal("expected aggregate memory limit error") + } +} From 84170de5ecf4fdba8c6602209f870453d930c133 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 13:43:54 +0530 Subject: [PATCH 087/149] fix(remote): bound SSH command execution Signed-off-by: vibhatsu --- pkg/remoteManager/ssh.go | 80 ++++++++++++++++++++++++---- pkg/remoteManager/ssh_output_test.go | 17 ++++++ 2 files changed, 87 insertions(+), 10 deletions(-) create mode 100644 pkg/remoteManager/ssh_output_test.go diff --git a/pkg/remoteManager/ssh.go b/pkg/remoteManager/ssh.go index 08949141..3414bc02 100644 --- a/pkg/remoteManager/ssh.go +++ b/pkg/remoteManager/ssh.go @@ -1,6 +1,7 @@ package remoteManager import ( + "bytes" "errors" "fmt" "io/ioutil" @@ -8,6 +9,7 @@ import ( "sort" "strings" "sync" + "time" "github.com/sdslabs/beastv4/core/config" log "github.com/sirupsen/logrus" @@ -15,6 +17,43 @@ import ( "golang.org/x/crypto/ssh/knownhosts" ) +const ( + defaultRemoteCommandTimeout = 2 * time.Minute + remoteBuildTimeout = 30 * time.Minute + maxRemoteCommandOutput = 4 << 20 +) + +var errRemoteOutputLimit = errors.New("remote command output exceeds limit") + +type boundedCommandOutput struct { + mu sync.Mutex + buffer bytes.Buffer + limit int + exceeded bool +} + +func (output *boundedCommandOutput) Write(data []byte) (int, error) { + output.mu.Lock() + defer output.mu.Unlock() + remaining := output.limit - output.buffer.Len() + if remaining <= 0 { + output.exceeded = true + return 0, errRemoteOutputLimit + } + if len(data) > remaining { + _, _ = output.buffer.Write(data[:remaining]) + output.exceeded = true + return remaining, errRemoteOutputLimit + } + return output.buffer.Write(data) +} + +func (output *boundedCommandOutput) String() string { + output.mu.Lock() + defer output.mu.Unlock() + return output.buffer.String() +} + type LoadBalancerQueue struct { servers []config.AvailableServer mu sync.Mutex @@ -91,7 +130,7 @@ func PingServer(server config.AvailableServer) error { } // Run the command passed as argument on the remote server -func runCommandOnServer(server config.AvailableServer, cmd string) (string, error) { +func runCommandOnServer(server config.AvailableServer, cmd string, timeout time.Duration) (string, error) { if !server.Active { return "", fmt.Errorf("server is inactive in config.toml") } @@ -106,25 +145,45 @@ func runCommandOnServer(server config.AvailableServer, cmd string) (string, erro defer client.Close() defer session.Close() - output, err := session.CombinedOutput(cmd) - if err != nil { + output := &boundedCommandOutput{limit: maxRemoteCommandOutput} + session.Stdout = output + session.Stderr = output + done := make(chan error, 1) + go func() { + done <- session.Run(cmd) + }() + timer := time.NewTimer(timeout) + defer timer.Stop() + var commandErr error + select { + case commandErr = <-done: + case <-timer.C: + _ = session.Close() + _ = client.Close() + return output.String(), fmt.Errorf("remote command timed out after %s", timeout) + } + outputText := output.String() + if output.exceeded { + return outputText, errRemoteOutputLimit + } + if commandErr != nil { exitStatus := -1 var exitError *ssh.ExitError - if errors.As(err, &exitError) { + if errors.As(commandErr, &exitError) { exitStatus = exitError.ExitStatus() } - return string(output), &RemoteCommandError{ExitStatus: exitStatus, Output: string(output), Err: err} + return outputText, &RemoteCommandError{ExitStatus: exitStatus, Output: outputText, Err: commandErr} } - log.Debugf("Command output for cmd %s : %s\n", cmd, output) - return string(output), nil + log.Debugf("Remote command completed on %s with %d output bytes", server.Host, len(outputText)) + return outputText, nil } func RunArgsOnServer(server config.AvailableServer, arguments ...string) (string, error) { if len(arguments) == 0 { return "", fmt.Errorf("remote command arguments are empty") } - return runCommandOnServer(server, "exec "+shellJoin(arguments)) + return runCommandOnServer(server, "exec "+shellJoin(arguments), defaultRemoteCommandTimeout) } func RunArgsInDirOnServer(server config.AvailableServer, directory string, arguments ...string) (string, error) { @@ -132,7 +191,7 @@ func RunArgsInDirOnServer(server config.AvailableServer, directory string, argum return "", fmt.Errorf("remote directory and command arguments are required") } command := "cd -- " + shellQuote(directory) + " && exec " + shellJoin(arguments) - return runCommandOnServer(server, command) + return runCommandOnServer(server, command, remoteBuildTimeout) } func RunArgsWithEnvOnServer(server config.AvailableServer, environment map[string]string, arguments ...string) (string, error) { @@ -156,7 +215,7 @@ func RunArgsWithEnvOnServer(server config.AvailableServer, environment map[strin command += " " } command += "exec " + shellJoin(arguments) - return runCommandOnServer(server, command) + return runCommandOnServer(server, command, defaultRemoteCommandTimeout) } // Creates an SSH client to connect to the remote server. @@ -183,6 +242,7 @@ func CreateSSHClient(remoteServer config.AvailableServer) (*ssh.Client, error) { ssh.PublicKeys(signer), }, HostKeyCallback: hostKeyCallback, + Timeout: 10 * time.Second, } client, err := ssh.Dial("tcp", fmt.Sprintf("%s:22", remoteServer.Host), config) diff --git a/pkg/remoteManager/ssh_output_test.go b/pkg/remoteManager/ssh_output_test.go new file mode 100644 index 00000000..dec9e0a0 --- /dev/null +++ b/pkg/remoteManager/ssh_output_test.go @@ -0,0 +1,17 @@ +package remoteManager + +import ( + "errors" + "testing" +) + +func TestBoundedCommandOutputStopsAtLimit(t *testing.T) { + output := &boundedCommandOutput{limit: 4} + written, err := output.Write([]byte("abcdef")) + if written != 4 || !errors.Is(err, errRemoteOutputLimit) { + t.Fatalf("write = %d, %v", written, err) + } + if got := output.String(); got != "abcd" || !output.exceeded { + t.Fatalf("output = %q, exceeded = %v", got, output.exceeded) + } +} From 8a3cd2b840a2734e509bedbfa18f3540dd99a04b Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 13:45:54 +0530 Subject: [PATCH 088/149] fix(build): bound local image builds Signed-off-by: vibhatsu --- core/manager/pipeline.go | 7 ++- pkg/cr/build_output_test.go | 18 +++++++ pkg/cr/images.go | 101 ++++++++++++++++++++++++++++++++---- 3 files changed, 114 insertions(+), 12 deletions(-) create mode 100644 pkg/cr/build_output_test.go diff --git a/core/manager/pipeline.go b/core/manager/pipeline.go index 1a9e366b..f20c02ee 100644 --- a/core/manager/pipeline.go +++ b/core/manager/pipeline.go @@ -187,7 +187,12 @@ func commitChallenge(challenge *database.Challenge, config cfg.BeastChallengeCon } else { if cfg.Cfg.UseLocalDockerDaemon(challenge.ServerDeployed) { var buff *bytes.Buffer - buff, imageId, buildErr = cr.BuildImageFromTarContext(challengeName, challengeTag, stagedPath, config.Challenge.Env.DockerCtx, noCache) + buff, imageId, buildErr = cr.BuildImageFromTarContext(challengeName, challengeTag, stagedPath, config.Challenge.Env.DockerCtx, noCache, cr.BuildLimits{ + CPUShares: config.Resources.CPUShares, + CPUs: config.Resources.CPUsLimit, + Memory: config.Resources.Memory, + Pids: config.Resources.PidsLimit, + }) if buff != nil { logBytes = buff.Bytes() } else { diff --git a/pkg/cr/build_output_test.go b/pkg/cr/build_output_test.go new file mode 100644 index 00000000..31017a3d --- /dev/null +++ b/pkg/cr/build_output_test.go @@ -0,0 +1,18 @@ +package cr + +import ( + "errors" + "testing" +) + +func TestBoundedBuildOutputStopsAtLimit(t *testing.T) { + output := &boundedBuildOutput{} + data := make([]byte, maxBuildOutput+1) + written, err := output.Write(data) + if written != maxBuildOutput || !errors.Is(err, errBuildOutputLimit) { + t.Fatalf("write = %d, %v", written, err) + } + if !output.exceeded || output.Buffer().Len() != maxBuildOutput { + t.Fatalf("output limit was not recorded") + } +} diff --git a/pkg/cr/images.go b/pkg/cr/images.go index 4528f652..5bb85a72 100644 --- a/pkg/cr/images.go +++ b/pkg/cr/images.go @@ -2,20 +2,67 @@ package cr import ( "bytes" + "context" + "errors" "fmt" + "io" "os" "os/exec" "path/filepath" + "sync" + "time" "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/filters" + "github.com/docker/go-units" "github.com/sdslabs/beastv4/core" "github.com/sdslabs/beastv4/utils" log "github.com/sirupsen/logrus" - "golang.org/x/net/context" ) +const ( + maxBuildOutput = 4 << 20 + buildTimeout = 30 * time.Minute +) + +var errBuildOutputLimit = errors.New("build output exceeds 4 MiB limit") + +type BuildLimits struct { + CPUShares int64 + CPUs float32 + Memory int64 + Pids int64 +} + +type boundedBuildOutput struct { + mu sync.Mutex + buffer bytes.Buffer + exceeded bool +} + +func (output *boundedBuildOutput) Write(data []byte) (int, error) { + output.mu.Lock() + defer output.mu.Unlock() + remaining := maxBuildOutput - output.buffer.Len() + if remaining <= 0 { + output.exceeded = true + return 0, errBuildOutputLimit + } + if len(data) > remaining { + _, _ = output.buffer.Write(data[:remaining]) + output.exceeded = true + return remaining, errBuildOutputLimit + } + return output.buffer.Write(data) +} + +func (output *boundedBuildOutput) Buffer() *bytes.Buffer { + output.mu.Lock() + defer output.mu.Unlock() + return bytes.NewBuffer(append([]byte(nil), output.buffer.Bytes()...)) +} + func RemoveImage(imageId string) error { cli, err := newDockerClient() if err != nil { @@ -68,8 +115,9 @@ func SearchImageByFilter(filterMap map[string]string) ([]types.ImageSummary, err return images, err } -func BuildImageFromTarContext(challengeName, challengeTag, tarContextPath, dockerCtxFile string, noCache bool) (*bytes.Buffer, string, error) { - ctx := context.Background() +func BuildImageFromTarContext(challengeName, challengeTag, tarContextPath, dockerCtxFile string, noCache bool, limits BuildLimits) (*bytes.Buffer, string, error) { + ctx, cancel := context.WithTimeout(context.Background(), buildTimeout) + defer cancel() builderContext, err := os.Open(tarContextPath) if err != nil { return nil, "", fmt.Errorf("error while opening staged file :: %s", tarContextPath) @@ -81,6 +129,16 @@ func BuildImageFromTarContext(challengeName, challengeTag, tarContextPath, docke Remove: true, Dockerfile: dockerCtxFile, NoCache: noCache, + CPUShares: limits.CPUShares, + CPUPeriod: 100000, + CPUQuota: int64(limits.CPUs * 100000), + Memory: limits.Memory, + MemorySwap: limits.Memory, + Ulimits: []*units.Ulimit{{ + Name: "nproc", + Soft: limits.Pids, + Hard: limits.Pids, + }}, Labels: map[string]string{ "beast.challenge": challengeName, "com.sdslabs.beast.project": utils.ProjectNameNotInstanced(challengeName), @@ -92,6 +150,7 @@ func BuildImageFromTarContext(challengeName, challengeTag, tarContextPath, docke if err != nil { return nil, "", fmt.Errorf("error while creating a docker client for beast: %s", err) } + defer dockerClient.Close() log.Debug("Image build in process") imageBuildResp, err := dockerClient.ImageBuild(ctx, builderContext, buildOptions) @@ -101,15 +160,27 @@ func BuildImageFromTarContext(challengeName, challengeTag, tarContextPath, docke defer imageBuildResp.Body.Close() buf := new(bytes.Buffer) - buf.ReadFrom(imageBuildResp.Body) + written, err := io.Copy(buf, io.LimitReader(imageBuildResp.Body, maxBuildOutput+1)) + if err != nil { + return buf, "", fmt.Errorf("read image build output: %w", err) + } + if written > maxBuildOutput { + return buf, "", errBuildOutputLimit + } + if err := ctx.Err(); err != nil { + return buf, "", fmt.Errorf("image build deadline: %w", err) + } images, err := SearchImageByFilter(map[string]string{"reference": fmt.Sprintf("%s:latest", challengeTag)}) + if err != nil { + return buf, "", fmt.Errorf("find built image: %w", err) + } if len(images) > 0 { log.Infof("Image ID for the image built is : %s", images[0].ID[7:]) return buf, images[0].ID[7:], nil } - return buf, "", err + return buf, "", fmt.Errorf("Docker build completed without producing image %s:latest", challengeTag) } // TODO: find a better way to build images from docker-compose instead of cmd running @@ -133,14 +204,22 @@ func BuildImagesFromCompose(challengeName, challengeTag, stagedPath, ComposeFile log.Debugf("Building image for challenge %s with tag %s", challengeName, challengeTag) log.Debugf("Running the command: docker %v", cmdArgs) - cmd := exec.Command("docker", cmdArgs...) + ctx, cancel := context.WithTimeout(context.Background(), buildTimeout) + defer cancel() + cmd := exec.CommandContext(ctx, "docker", cmdArgs...) cmd.Dir = extractPath - var outBuffer bytes.Buffer - cmd.Stdout = &outBuffer - cmd.Stderr = &outBuffer + output := &boundedBuildOutput{} + cmd.Stdout = output + cmd.Stderr = output if err := cmd.Run(); err != nil { - return &outBuffer, fmt.Errorf("error while building image for challenge %s with tag %s: %v", challengeName, challengeTag, err) + return output.Buffer(), fmt.Errorf("error while building image for challenge %s with tag %s: %v", challengeName, challengeTag, err) + } + if output.exceeded { + return output.Buffer(), errBuildOutputLimit + } + if err := ctx.Err(); err != nil { + return output.Buffer(), fmt.Errorf("Compose build deadline: %w", err) } - return &outBuffer, nil + return output.Buffer(), nil } From 9a621b838b90ca4630a612f2dbb486700bde5127 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 13:46:36 +0530 Subject: [PATCH 089/149] fix(build): bound remote image resources Signed-off-by: vibhatsu --- core/manager/pipeline.go | 7 ++++++- pkg/remoteManager/file.go | 9 ++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/core/manager/pipeline.go b/core/manager/pipeline.go index f20c02ee..21738521 100644 --- a/core/manager/pipeline.go +++ b/core/manager/pipeline.go @@ -206,7 +206,12 @@ func commitChallenge(challenge *database.Challenge, config cfg.BeastChallengeCon if err != nil { return fmt.Errorf("error while checking if the challenge is staged on the remote server") } - logBytes, imageId, buildErr = remoteManager.BuildImageFromTarContextRemote(challengeName, challengeTag, remoteStagedPath, server) + logBytes, imageId, buildErr = remoteManager.BuildImageFromTarContextRemote(challengeName, challengeTag, remoteStagedPath, server, cr.BuildLimits{ + CPUShares: config.Resources.CPUShares, + CPUs: config.Resources.CPUsLimit, + Memory: config.Resources.Memory, + Pids: config.Resources.PidsLimit, + }) } } // Create logs directory for the challenge in staging directory. diff --git a/pkg/remoteManager/file.go b/pkg/remoteManager/file.go index 4d63f604..2a4f3ad8 100644 --- a/pkg/remoteManager/file.go +++ b/pkg/remoteManager/file.go @@ -11,6 +11,7 @@ import ( "github.com/sdslabs/beastv4/core" "github.com/sdslabs/beastv4/core/config" "github.com/sdslabs/beastv4/core/database" + "github.com/sdslabs/beastv4/pkg/cr" "github.com/sdslabs/beastv4/utils" ) @@ -68,7 +69,7 @@ func StageChallRemote(server config.AvailableServer, challenge database.Challeng } // BuildImageFromTarContextRemote builds a Docker image from the tar context on the remote server. -func BuildImageFromTarContextRemote(challengeName string, imageTag string, stagedDir string, server config.AvailableServer) ([]byte, string, error) { +func BuildImageFromTarContextRemote(challengeName string, imageTag string, stagedDir string, server config.AvailableServer, limits cr.BuildLimits) ([]byte, string, error) { remoteExtractPath := filepath.Join(core.BEAST_REMOTE_GLOBAL_DIR, core.BEAST_STAGING_DIR, challengeName, challengeName) _, err := RunArgsOnServer(server, "mkdir", "-p", remoteExtractPath) if err == nil { @@ -80,6 +81,12 @@ func BuildImageFromTarContextRemote(challengeName string, imageTag string, stage projectName := utils.ProjectNameNotInstanced(challengeName) output, err := RunArgsInDirOnServer(server, remoteExtractPath, "docker", "build", "-t", imageTag, + "--cpu-shares", fmt.Sprintf("%d", limits.CPUShares), + "--cpu-period", "100000", + "--cpu-quota", fmt.Sprintf("%d", int64(limits.CPUs*100000)), + "--memory", fmt.Sprintf("%d", limits.Memory), + "--memory-swap", fmt.Sprintf("%d", limits.Memory), + "--ulimit", fmt.Sprintf("nproc=%d:%d", limits.Pids, limits.Pids), "--label", "beast.challenge="+challengeName, "--label", "com.sdslabs.beast.project="+projectName, "--label", "com.docker.compose.project="+projectName, ".") From c4c19f222318cfd1ced01377e9a43d8e14c7dc3c Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 13:48:45 +0530 Subject: [PATCH 090/149] fix(build): store logs privately Signed-off-by: vibhatsu --- core/manager/pipeline.go | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/core/manager/pipeline.go b/core/manager/pipeline.go index 21738521..5cd1318e 100644 --- a/core/manager/pipeline.go +++ b/core/manager/pipeline.go @@ -5,7 +5,6 @@ import ( "fmt" "os" "path/filepath" - "time" "github.com/sdslabs/beastv4/core" cfg "github.com/sdslabs/beastv4/core/config" @@ -216,19 +215,24 @@ func commitChallenge(challenge *database.Challenge, config cfg.BeastChallengeCon } // Create logs directory for the challenge in staging directory. challengeStagingLogsDir := filepath.Join(challengeStagingDir, core.BEAST_CHALLENGE_LOGS_DIR) - err = utils.CreateIfNotExistDir(challengeStagingLogsDir) + err = os.MkdirAll(challengeStagingLogsDir, 0700) if err != nil { log.Errorf("Could not create challenge logs directory : %s : %s", challengeStagingLogsDir, err) } else if logBytes != nil { - logFilePath := filepath.Join(challengeStagingLogsDir, fmt.Sprintf("%s.%s.log", challengeName, time.Now().Format("20060102150405"))) - logFile, err := os.OpenFile(logFilePath, os.O_WRONLY|os.O_TRUNC|os.O_CREATE, 0755) + if err := os.Chmod(challengeStagingLogsDir, 0700); err != nil { + return fmt.Errorf("secure build log directory: %w", err) + } + logFile, err := os.CreateTemp(challengeStagingLogsDir, challengeName+".*.log") if err != nil { - log.Errorf("Error while writing logs to file : %s", logFilePath) - return fmt.Errorf("error logs generated on image build failure could not be written to the logfile") + return fmt.Errorf("create build log: %w", err) + } + if _, err := logFile.Write(logBytes); err != nil { + _ = logFile.Close() + return fmt.Errorf("write build log: %w", err) + } + if err := logFile.Close(); err != nil { + return fmt.Errorf("close build log: %w", err) } - defer logFile.Close() - - logFile.Write(logBytes) log.Debug("Logs written to log file for the challenge") } From 8df77283802cba4ff01b23037b046a5033a2438d Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 13:50:43 +0530 Subject: [PATCH 091/149] fix(docker): close API clients reliably Signed-off-by: vibhatsu --- pkg/cr/containers.go | 9 +++++++++ pkg/cr/exec.go | 1 + pkg/cr/images.go | 3 +++ 3 files changed, 13 insertions(+) diff --git a/pkg/cr/containers.go b/pkg/cr/containers.go index 2a8caa9d..bc7604f8 100644 --- a/pkg/cr/containers.go +++ b/pkg/cr/containers.go @@ -93,6 +93,7 @@ func SearchContainerByFilter(filterMap map[string]string) ([]types.Container, er if err != nil { return []types.Container{}, err } + defer cli.Close() filterArgs := filters.NewArgs() for key, val := range filterMap { @@ -113,6 +114,7 @@ func SearchRunningContainerByFilter(filterMap map[string]string) ([]types.Contai if err != nil { return []types.Container{}, err } + defer cli.Close() filterArgs := filters.NewArgs() for key, val := range filterMap { @@ -131,6 +133,7 @@ func StopAndRemoveContainer(containerId string) error { if err != nil { return err } + defer cli.Close() // Try to stop using default timeout we are using for beast err = cli.ContainerStop(context.Background(), containerId, &defaults.DefaultDockerStopTimeout) @@ -156,6 +159,7 @@ func CreateContainerFromImage(containerConfig *CreateContainerConfig) (string, e if err != nil { return "", err } + defer cli.Close() portSet := make(nat.PortSet) portMap := make(nat.PortMap) @@ -254,6 +258,7 @@ func GetContainerStdLogs(containerID string) (*Log, error) { if err != nil { return nil, err } + defer cli.Close() stdout, err := cli.ContainerLogs(context.Background(), containerID, types.ContainerLogsOptions{ ShowStdout: true, @@ -284,7 +289,9 @@ func ShowLiveContainerLogs(containerID string) { cli, err := newDockerClient() if err != nil { log.Error(err) + return } + defer cli.Close() stream, err := cli.ContainerLogs(context.Background(), containerID, types.ContainerLogsOptions{ ShowStdout: true, @@ -293,6 +300,7 @@ func ShowLiveContainerLogs(containerID string) { }) if err != nil { log.Error(err) + return } defer stream.Close() @@ -306,6 +314,7 @@ func CommitContainer(containerId string) (string, error) { if err != nil { return "", err } + defer cli.Close() commitResp, err := cli.ContainerCommit(ctx, containerId, types.ContainerCommitOptions{}) if err != nil { diff --git a/pkg/cr/exec.go b/pkg/cr/exec.go index 8c16fbf0..b69fc242 100644 --- a/pkg/cr/exec.go +++ b/pkg/cr/exec.go @@ -68,6 +68,7 @@ func ExecContainer(ctx context.Context, containerID string, command []string, ou if err != nil { return ExecResult{}, fmt.Errorf("create Docker client: %w", err) } + defer client.Close() exec, err := client.ContainerExecCreate(ctx, containerID, types.ExecConfig{ User: "0", AttachStdout: true, diff --git a/pkg/cr/images.go b/pkg/cr/images.go index 5bb85a72..788b5f2b 100644 --- a/pkg/cr/images.go +++ b/pkg/cr/images.go @@ -68,6 +68,7 @@ func RemoveImage(imageId string) error { if err != nil { return err } + defer cli.Close() _, err = cli.ImageRemove(context.Background(), imageId, types.ImageRemoveOptions{ Force: false, @@ -83,6 +84,7 @@ func CheckIfImageExists(imageId string) (bool, error) { if err != nil { return false, err } + defer cli.Close() inspectVal, _, err := cli.ImageInspectWithRaw(ctx, imageId) if err != nil { @@ -101,6 +103,7 @@ func SearchImageByFilter(filterMap map[string]string) ([]types.ImageSummary, err if err != nil { return []types.ImageSummary{}, err } + defer cli.Close() filterArgs := filters.NewArgs() for key, val := range filterMap { From 4d959aa47746359b0dece85bd26ba67df07ceef8 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 13:51:37 +0530 Subject: [PATCH 092/149] fix(logs): bound container output reads Signed-off-by: vibhatsu --- core/utils/logs.go | 8 +++++-- pkg/cr/container_logs_test.go | 22 ++++++++++++++++++ pkg/cr/containers.go | 42 +++++++++++++++++++++++++++-------- 3 files changed, 61 insertions(+), 11 deletions(-) create mode 100644 pkg/cr/container_logs_test.go diff --git a/core/utils/logs.go b/core/utils/logs.go index 956358e7..d7de21d9 100644 --- a/core/utils/logs.go +++ b/core/utils/logs.go @@ -48,10 +48,14 @@ func GetLogs(challname string, live bool) (*cr.Log, error) { if live { if config.Cfg.UseLocalDockerDaemon(chall.ServerDeployed) { - cr.ShowLiveContainerLogs(chall.ContainerId) + if err := cr.ShowLiveContainerLogs(chall.ContainerId); err != nil { + return nil, err + } } else { server := config.Cfg.AvailableServers[chall.ServerDeployed] - remoteManager.ShowLiveContainerLogsRemote(chall.ContainerId, server) + if err := remoteManager.ShowLiveContainerLogsRemote(chall.ContainerId, server); err != nil { + return nil, err + } } return nil, nil } diff --git a/pkg/cr/container_logs_test.go b/pkg/cr/container_logs_test.go new file mode 100644 index 00000000..6e9685cc --- /dev/null +++ b/pkg/cr/container_logs_test.go @@ -0,0 +1,22 @@ +package cr + +import ( + "errors" + "strings" + "testing" +) + +func TestReadContainerLogsEnforcesLimit(t *testing.T) { + logs, err := readContainerLogs(strings.NewReader("test"), 4) + if err != nil { + t.Fatalf("read logs at limit: %v", err) + } + if string(logs) != "test" { + t.Fatalf("unexpected logs: %q", logs) + } + + _, err = readContainerLogs(strings.NewReader("tests"), 4) + if !errors.Is(err, errContainerLogLimit) { + t.Fatalf("expected log limit error, got %v", err) + } +} diff --git a/pkg/cr/containers.go b/pkg/cr/containers.go index bc7604f8..d9f44d10 100644 --- a/pkg/cr/containers.go +++ b/pkg/cr/containers.go @@ -3,8 +3,9 @@ package cr import ( "bytes" "encoding/json" + "errors" "fmt" - "io/ioutil" + "io" "os" "os/exec" "path/filepath" @@ -42,8 +43,12 @@ const ( UDPTraffic TrafficType = "udp" DefaultTraffic TrafficType = TCPTraffic + + maxContainerLogBytes int64 = 4 << 20 ) +var errContainerLogLimit = errors.New("container logs exceed 4 MiB limit") + func IsValidTrafficType(t string) bool { switch TrafficType(t) { case TCPTraffic, UDPTraffic: @@ -269,7 +274,10 @@ func GetContainerStdLogs(containerID string) (*Log, error) { } defer stdout.Close() - stdoutlogs, _ := ioutil.ReadAll(stdout) + stdoutlogs, err := readContainerLogs(stdout, maxContainerLogBytes) + if err != nil { + return nil, fmt.Errorf("read container stdout: %w", err) + } stderr, err := cli.ContainerLogs(context.Background(), containerID, types.ContainerLogsOptions{ ShowStderr: true, @@ -280,16 +288,29 @@ func GetContainerStdLogs(containerID string) (*Log, error) { } defer stderr.Close() - stderrlogs, _ := ioutil.ReadAll(stderr) + stderrlogs, err := readContainerLogs(stderr, maxContainerLogBytes-int64(len(stdoutlogs))) + if err != nil { + return nil, fmt.Errorf("read container stderr: %w", err) + } return &Log{Stdout: string(stdoutlogs), Stderr: string(stderrlogs)}, nil } -func ShowLiveContainerLogs(containerID string) { +func readContainerLogs(reader io.Reader, limit int64) ([]byte, error) { + logs, err := io.ReadAll(io.LimitReader(reader, limit+1)) + if err != nil { + return nil, err + } + if int64(len(logs)) > limit { + return nil, errContainerLogLimit + } + return logs, nil +} + +func ShowLiveContainerLogs(containerID string) error { cli, err := newDockerClient() if err != nil { - log.Error(err) - return + return err } defer cli.Close() @@ -299,13 +320,16 @@ func ShowLiveContainerLogs(containerID string) { Details: true, }) if err != nil { - log.Error(err) - return + return err } defer stream.Close() - logs, _ := ioutil.ReadAll(stream) + logs, err := readContainerLogs(stream, maxContainerLogBytes) + if err != nil { + return fmt.Errorf("read container logs: %w", err) + } fmt.Println(string(logs)) + return nil } func CommitContainer(containerId string) (string, error) { From 0f7948973063370e87ab9161da1bd34a6e43ef6e Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 13:55:56 +0530 Subject: [PATCH 093/149] fix(compose): bound lifecycle commands Signed-off-by: vibhatsu --- pkg/cr/containers.go | 75 ++++++++++++------------------- pkg/cr/health_check.go | 33 +++++--------- pkg/cr/runtime_command.go | 80 ++++++++++++++++++++++++++++++++++ pkg/cr/runtime_command_test.go | 14 ++++++ 4 files changed, 134 insertions(+), 68 deletions(-) create mode 100644 pkg/cr/runtime_command.go create mode 100644 pkg/cr/runtime_command_test.go diff --git a/pkg/cr/containers.go b/pkg/cr/containers.go index d9f44d10..f1f0f695 100644 --- a/pkg/cr/containers.go +++ b/pkg/cr/containers.go @@ -1,13 +1,11 @@ package cr import ( - "bytes" "encoding/json" "errors" "fmt" "io" "os" - "os/exec" "path/filepath" "strconv" "strings" @@ -356,35 +354,29 @@ func DeployContainerFromCompose(challengeName string, projectName string, staged // Deploy with project name - Docker Compose automatically labels containers with // com.docker.compose.project= - upCmd := exec.Command("docker", "compose", + arguments := []string{"compose", "-f", composeFile, "-p", projectName, - "up", "-d") + "up", "-d"} environment := os.Environ() for variable, port := range ports { environment = append(environment, fmt.Sprintf("%s=%s", variable, strconv.FormatUint(uint64(port), 10))) } - upCmd.Env = environment - - var upOutput bytes.Buffer - upCmd.Stdout = &upOutput - upCmd.Stderr = &upOutput - - if err := upCmd.Run(); err != nil { - log.Errorf("docker compose up failed for challenge %s. Output:\n%s", challengeName, upOutput.String()) - return "", fmt.Errorf("error while running docker compose up: %v", err) + upOutput, err := runRuntimeCommand("docker", arguments, runtimeCommandOptions{environment: environment}) + if err != nil { + log.Errorf("docker compose up failed for challenge %s. Output:\n%s", challengeName, upOutput) + return "", cleanupFailedComposeDeployment(projectName, fmt.Errorf("run docker compose up: %w", err)) } if err := validateAllComposeServicesRunning(projectName, challengeName); err != nil { - return "", err + return "", cleanupFailedComposeDeployment(projectName, err) } primaryContainerId, err := getPrimaryComposeContainerId(projectName) if err != nil { - log.Warnf("Could not get primary container ID for challenge %s: %v", challengeName, err) - return "", nil // Return empty string but success + return "", cleanupFailedComposeDeployment(projectName, fmt.Errorf("get primary container for challenge %s: %w", challengeName, err)) } log.Debugf("Verified challenge %s services are running. Primary container: %s", challengeName, primaryContainerId) @@ -392,16 +384,12 @@ func DeployContainerFromCompose(challengeName string, projectName string, staged } func validateAllComposeServicesRunning(projectName, challengeName string) error { - psCmd := exec.Command("docker", "compose", "-p", projectName, "ps", "--format", "json") - var psOutput bytes.Buffer - psCmd.Stdout = &psOutput - psCmd.Stderr = &psOutput - - if err := psCmd.Run(); err != nil { - return fmt.Errorf("error checking container status after compose up for challenge %s. Output:\n%s", challengeName, psOutput.String()) + psOutput, err := runRuntimeCommand("docker", []string{"compose", "-p", projectName, "ps", "--format", "json"}, runtimeCommandOptions{}) + if err != nil { + return fmt.Errorf("error checking container status after compose up for challenge %s: %w. Output:\n%s", challengeName, err, psOutput) } - output := strings.TrimSpace(psOutput.String()) + output := strings.TrimSpace(psOutput) if output == "" { return fmt.Errorf("no services found after compose up for challenge %s", challengeName) } @@ -452,15 +440,12 @@ func validateAllComposeServicesRunning(projectName, challengeName string) error // gets the first container ID from a compose project func getPrimaryComposeContainerId(projectName string) (string, error) { - psCmd := exec.Command("docker", "compose", "-p", projectName, "ps", "-q") - var output bytes.Buffer - psCmd.Stdout = &output - - if err := psCmd.Run(); err != nil { + output, err := runRuntimeCommand("docker", []string{"compose", "-p", projectName, "ps", "-q"}, runtimeCommandOptions{}) + if err != nil { return "", fmt.Errorf("failed to get container IDs: %v", err) } - containerIds := strings.Fields(strings.TrimSpace(output.String())) + containerIds := strings.Fields(strings.TrimSpace(output)) if len(containerIds) == 0 { return "", fmt.Errorf("no containers found for project %s", projectName) } @@ -476,13 +461,9 @@ func getPrimaryComposeContainerId(projectName string) (string, error) { func ComposeDownProject(projectName string) error { log.Debugf("Stopping docker compose project %s", projectName) - downCmd := exec.Command("docker", "compose", "-p", projectName, "down") - var downOutput bytes.Buffer - downCmd.Stdout = &downOutput - downCmd.Stderr = &downOutput - - if err := downCmd.Run(); err != nil { - return fmt.Errorf("docker compose down failed for project %s: %v. Output: %s", projectName, err, downOutput.String()) + downOutput, err := runRuntimeCommand("docker", []string{"compose", "-p", projectName, "down"}, runtimeCommandOptions{}) + if err != nil { + return fmt.Errorf("docker compose down failed for project %s: %v. Output: %s", projectName, err, downOutput) } log.Debugf("Successfully stopped compose project %s", projectName) @@ -493,17 +474,19 @@ func ComposeDownProject(projectName string) error { func ComposePurgeProject(projectName string) error { log.Debugf("Purging docker compose project %s", projectName) - purgeCmd := exec.Command("docker", "compose", "-p", projectName, - "down", "--remove-orphans", "--volumes", "--rmi", "all") - - var purgeOutput bytes.Buffer - purgeCmd.Stdout = &purgeOutput - purgeCmd.Stderr = &purgeOutput - - if err := purgeCmd.Run(); err != nil { - return fmt.Errorf("docker compose purge failed for project %s: %v. Output: %s", projectName, err, purgeOutput.String()) + purgeOutput, err := runRuntimeCommand("docker", []string{"compose", "-p", projectName, + "down", "--remove-orphans", "--volumes", "--rmi", "all"}, runtimeCommandOptions{}) + if err != nil { + return fmt.Errorf("docker compose purge failed for project %s: %v. Output: %s", projectName, err, purgeOutput) } log.Debugf("Successfully purged compose project %s", projectName) return nil } + +func cleanupFailedComposeDeployment(projectName string, deploymentErr error) error { + if cleanupErr := ComposeDownProject(projectName); cleanupErr != nil { + return errors.Join(deploymentErr, fmt.Errorf("clean up failed compose deployment: %w", cleanupErr)) + } + return deploymentErr +} diff --git a/pkg/cr/health_check.go b/pkg/cr/health_check.go index 0291e2fb..bb18a5d4 100644 --- a/pkg/cr/health_check.go +++ b/pkg/cr/health_check.go @@ -1,15 +1,14 @@ package cr import ( - "bytes" "encoding/json" "fmt" + "strings" + "github.com/docker/docker/api/types" "github.com/sdslabs/beastv4/core" "github.com/sdslabs/beastv4/core/cache" log "github.com/sirupsen/logrus" - "os/exec" - "strings" ) func CleanupOrphans() { @@ -92,7 +91,7 @@ func CleanupOrphanedComposeInstances() { if err != nil { log.Infof("Removing orphaned compose instance project: %s (instance %s) on %s", projectName, instanceID, core.LOCALHOST) - err = composeDownProject(projectName) + err = composeDownOrphanProject(projectName) if err != nil { log.Warnf("Failed to remove orphaned compose project %s: %v", projectName, err) } @@ -106,13 +105,9 @@ func CleanupOrphanedComposeInstances() { // getOrphanedComposeInstanceProjects returns a list of docker compose project names // that match the instance naming pattern (beast-instance-*) func getOrphanedComposeInstanceProjects() ([]string, error) { - cmd := exec.Command("docker", "compose", "ls", "--format", "json") - var output bytes.Buffer - cmd.Stdout = &output - cmd.Stderr = &output - - if err := cmd.Run(); err != nil { - return nil, fmt.Errorf("docker compose ls failed: %v, output: %s", err, output.String()) + output, err := runRuntimeCommand("docker", []string{"compose", "ls", "--format", "json"}, runtimeCommandOptions{}) + if err != nil { + return nil, fmt.Errorf("docker compose ls failed: %v, output: %s", err, output) } type ComposeProject struct { @@ -121,7 +116,7 @@ func getOrphanedComposeInstanceProjects() ([]string, error) { } var projects []ComposeProject - outputStr := strings.TrimSpace(output.String()) + outputStr := strings.TrimSpace(output) if outputStr == "" { return nil, nil } @@ -150,17 +145,11 @@ func getOrphanedComposeInstanceProjects() ([]string, error) { return instanceProjects, nil } -// composeDownProject removes a docker compose project by name -func composeDownProject(projectName string) error { - cmd := exec.Command("docker", "compose", "-p", projectName, "down", "--remove-orphans", "-v") - var output bytes.Buffer - cmd.Stdout = &output - cmd.Stderr = &output - - if err := cmd.Run(); err != nil { - return fmt.Errorf("docker compose down failed: %v, output: %s", err, output.String()) +func composeDownOrphanProject(projectName string) error { + output, err := runRuntimeCommand("docker", []string{"compose", "-p", projectName, "down", "--remove-orphans", "-v"}, runtimeCommandOptions{}) + if err != nil { + return fmt.Errorf("docker compose down failed: %v, output: %s", err, output) } - log.Debugf("Successfully removed compose project %s", projectName) return nil } diff --git a/pkg/cr/runtime_command.go b/pkg/cr/runtime_command.go new file mode 100644 index 00000000..7986dfbe --- /dev/null +++ b/pkg/cr/runtime_command.go @@ -0,0 +1,80 @@ +package cr + +import ( + "bytes" + "context" + "errors" + "fmt" + "os/exec" + "sync" + "time" +) + +const ( + runtimeCommandTimeout = 2 * time.Minute + maxRuntimeCommandOutput = 1 << 20 +) + +var errRuntimeCommandOutputLimit = errors.New("runtime command output exceeds 1 MiB limit") + +type runtimeCommandOptions struct { + directory string + environment []string + timeout time.Duration +} + +type boundedRuntimeOutput struct { + mutex sync.Mutex + buffer bytes.Buffer + remaining int + truncated bool +} + +func (output *boundedRuntimeOutput) Write(data []byte) (int, error) { + output.mutex.Lock() + defer output.mutex.Unlock() + written := len(data) + if len(data) > output.remaining { + data = data[:output.remaining] + output.truncated = true + } + if len(data) > 0 { + _, _ = output.buffer.Write(data) + output.remaining -= len(data) + } + return written, nil +} + +func (output *boundedRuntimeOutput) String() string { + output.mutex.Lock() + defer output.mutex.Unlock() + return output.buffer.String() +} + +func runRuntimeCommand(name string, arguments []string, options runtimeCommandOptions) (string, error) { + timeout := options.timeout + if timeout <= 0 { + timeout = runtimeCommandTimeout + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + command := exec.CommandContext(ctx, name, arguments...) + command.Dir = options.directory + if options.environment != nil { + command.Env = options.environment + } + output := &boundedRuntimeOutput{remaining: maxRuntimeCommandOutput} + command.Stdout = output + command.Stderr = output + + err := command.Run() + outputText := output.String() + if ctx.Err() != nil { + return outputText, fmt.Errorf("%s timed out after %s: %w", name, timeout, ctx.Err()) + } + if output.truncated { + return outputText, errRuntimeCommandOutputLimit + } + return outputText, err +} diff --git a/pkg/cr/runtime_command_test.go b/pkg/cr/runtime_command_test.go new file mode 100644 index 00000000..cee83749 --- /dev/null +++ b/pkg/cr/runtime_command_test.go @@ -0,0 +1,14 @@ +package cr + +import "testing" + +func TestBoundedRuntimeOutputEnforcesLimit(t *testing.T) { + output := &boundedRuntimeOutput{remaining: 4} + written, err := output.Write([]byte("tests")) + if err != nil { + t.Fatalf("write bounded output: %v", err) + } + if written != 5 || output.String() != "test" || !output.truncated { + t.Fatalf("unexpected bounded output: written=%d output=%q truncated=%t", written, output.String(), output.truncated) + } +} From a932f1003dad9d80957d2ce7364d58ebd24cb697 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 13:57:11 +0530 Subject: [PATCH 094/149] fix(docker): deadline API operations Signed-off-by: vibhatsu --- pkg/cr/client.go | 11 ++++++++++- pkg/cr/containers.go | 32 ++++++++++++++++++++++---------- pkg/cr/images.go | 11 ++++++++--- 3 files changed, 40 insertions(+), 14 deletions(-) diff --git a/pkg/cr/client.go b/pkg/cr/client.go index 61368402..740ed2d3 100644 --- a/pkg/cr/client.go +++ b/pkg/cr/client.go @@ -1,6 +1,15 @@ package cr -import "github.com/docker/docker/client" +import ( + "time" + + "github.com/docker/docker/client" +) + +const ( + dockerAPIRequestTimeout = 30 * time.Second + dockerAPILongTimeout = 2 * time.Minute +) func newDockerClient() (*client.Client, error) { return client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) diff --git a/pkg/cr/containers.go b/pkg/cr/containers.go index f1f0f695..e81eae14 100644 --- a/pkg/cr/containers.go +++ b/pkg/cr/containers.go @@ -1,6 +1,7 @@ package cr import ( + "context" "encoding/json" "errors" "fmt" @@ -19,7 +20,6 @@ import ( utils "github.com/sdslabs/beastv4/utils" log "github.com/sirupsen/logrus" - "golang.org/x/net/context" ) type PortMapping struct { @@ -97,13 +97,15 @@ func SearchContainerByFilter(filterMap map[string]string) ([]types.Container, er return []types.Container{}, err } defer cli.Close() + ctx, cancel := context.WithTimeout(context.Background(), dockerAPIRequestTimeout) + defer cancel() filterArgs := filters.NewArgs() for key, val := range filterMap { filterArgs.Add(key, val) } - containers, err := cli.ContainerList(context.Background(), types.ContainerListOptions{ + containers, err := cli.ContainerList(ctx, types.ContainerListOptions{ All: true, Filters: filterArgs, }) @@ -118,13 +120,15 @@ func SearchRunningContainerByFilter(filterMap map[string]string) ([]types.Contai return []types.Container{}, err } defer cli.Close() + ctx, cancel := context.WithTimeout(context.Background(), dockerAPIRequestTimeout) + defer cancel() filterArgs := filters.NewArgs() for key, val := range filterMap { filterArgs.Add(key, val) } - containers, err := cli.ContainerList(context.Background(), types.ContainerListOptions{ + containers, err := cli.ContainerList(ctx, types.ContainerListOptions{ Filters: filterArgs, }) @@ -137,16 +141,18 @@ func StopAndRemoveContainer(containerId string) error { return err } defer cli.Close() + ctx, cancel := context.WithTimeout(context.Background(), dockerAPIRequestTimeout) + defer cancel() // Try to stop using default timeout we are using for beast - err = cli.ContainerStop(context.Background(), containerId, &defaults.DefaultDockerStopTimeout) + err = cli.ContainerStop(ctx, containerId, &defaults.DefaultDockerStopTimeout) if err != nil { return err } log.Debug("Stopped container with ID ", containerId) log.Debug("Removing container with ID ", containerId) - err = cli.ContainerRemove(context.Background(), containerId, types.ContainerRemoveOptions{ + err = cli.ContainerRemove(ctx, containerId, types.ContainerRemoveOptions{ RemoveVolumes: false, RemoveLinks: false, Force: true, @@ -157,7 +163,8 @@ func StopAndRemoveContainer(containerId string) error { func CreateContainerFromImage(containerConfig *CreateContainerConfig) (string, error) { containerName := containerConfig.ContainerName - ctx := context.Background() + ctx, cancel := context.WithTimeout(context.Background(), dockerAPILongTimeout) + defer cancel() cli, err := newDockerClient() if err != nil { return "", err @@ -262,8 +269,10 @@ func GetContainerStdLogs(containerID string) (*Log, error) { return nil, err } defer cli.Close() + ctx, cancel := context.WithTimeout(context.Background(), dockerAPIRequestTimeout) + defer cancel() - stdout, err := cli.ContainerLogs(context.Background(), containerID, types.ContainerLogsOptions{ + stdout, err := cli.ContainerLogs(ctx, containerID, types.ContainerLogsOptions{ ShowStdout: true, Details: true, }) @@ -277,7 +286,7 @@ func GetContainerStdLogs(containerID string) (*Log, error) { return nil, fmt.Errorf("read container stdout: %w", err) } - stderr, err := cli.ContainerLogs(context.Background(), containerID, types.ContainerLogsOptions{ + stderr, err := cli.ContainerLogs(ctx, containerID, types.ContainerLogsOptions{ ShowStderr: true, Details: true, }) @@ -311,8 +320,10 @@ func ShowLiveContainerLogs(containerID string) error { return err } defer cli.Close() + ctx, cancel := context.WithTimeout(context.Background(), dockerAPIRequestTimeout) + defer cancel() - stream, err := cli.ContainerLogs(context.Background(), containerID, types.ContainerLogsOptions{ + stream, err := cli.ContainerLogs(ctx, containerID, types.ContainerLogsOptions{ ShowStdout: true, ShowStderr: true, Details: true, @@ -331,7 +342,8 @@ func ShowLiveContainerLogs(containerID string) error { } func CommitContainer(containerId string) (string, error) { - ctx := context.Background() + ctx, cancel := context.WithTimeout(context.Background(), dockerAPILongTimeout) + defer cancel() cli, err := newDockerClient() if err != nil { return "", err diff --git a/pkg/cr/images.go b/pkg/cr/images.go index 788b5f2b..84f43e7e 100644 --- a/pkg/cr/images.go +++ b/pkg/cr/images.go @@ -69,8 +69,10 @@ func RemoveImage(imageId string) error { return err } defer cli.Close() + ctx, cancel := context.WithTimeout(context.Background(), dockerAPIRequestTimeout) + defer cancel() - _, err = cli.ImageRemove(context.Background(), imageId, types.ImageRemoveOptions{ + _, err = cli.ImageRemove(ctx, imageId, types.ImageRemoveOptions{ Force: false, PruneChildren: true, }) @@ -79,7 +81,8 @@ func RemoveImage(imageId string) error { } func CheckIfImageExists(imageId string) (bool, error) { - ctx := context.Background() + ctx, cancel := context.WithTimeout(context.Background(), dockerAPIRequestTimeout) + defer cancel() cli, err := newDockerClient() if err != nil { return false, err @@ -104,13 +107,15 @@ func SearchImageByFilter(filterMap map[string]string) ([]types.ImageSummary, err return []types.ImageSummary{}, err } defer cli.Close() + ctx, cancel := context.WithTimeout(context.Background(), dockerAPIRequestTimeout) + defer cancel() filterArgs := filters.NewArgs() for key, val := range filterMap { filterArgs.Add(key, val) } - images, err := cli.ImageList(context.Background(), types.ImageListOptions{ + images, err := cli.ImageList(ctx, types.ImageListOptions{ All: false, Filters: filterArgs, }) From adca5b863b1ca21ae9214bd7640e872b1be626b8 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 13:58:28 +0530 Subject: [PATCH 095/149] fix(remote): bound deployment lifecycle Signed-off-by: vibhatsu --- pkg/remoteManager/container.go | 17 ++++++++++++----- pkg/remoteManager/file.go | 30 +++++++++++++++++------------- 2 files changed, 29 insertions(+), 18 deletions(-) diff --git a/pkg/remoteManager/container.go b/pkg/remoteManager/container.go index 1adee812..b8d48eff 100644 --- a/pkg/remoteManager/container.go +++ b/pkg/remoteManager/container.go @@ -3,6 +3,7 @@ package remoteManager import ( "bytes" "encoding/json" + "errors" "fmt" "path/filepath" "sort" @@ -176,7 +177,7 @@ func GetContainerStdLogsRemote(containerID string, server config.AvailableServer // Get live logs of container func ShowLiveContainerLogsRemote(containerID string, server config.AvailableServer) error { - output, err := RunArgsOnServer(server, "docker", "logs", "--details", "--follow", containerID) + output, err := RunArgsOnServer(server, "docker", "logs", "--details", containerID) if err != nil { return fmt.Errorf("error streaming live logs: %w", err) } @@ -207,17 +208,16 @@ func DeployContainerFromComposeRemote(challengeName string, projectName string, upOutput, err := RunArgsWithEnvOnServer(server, environment, "docker", "compose", "-f", composeFile, "-p", projectName, "up", "-d") if err != nil { log.Errorf("docker compose up failed for challenge %s. Output:\n%s", challengeName, upOutput) - return "", fmt.Errorf("error while running docker compose up on remote: %v", err) + return "", cleanupFailedComposeDeploymentRemote(projectName, server, fmt.Errorf("run docker compose up on remote: %w", err)) } if err := validateAllComposeServicesRunningRemote(projectName, challengeName, server); err != nil { - return "", err + return "", cleanupFailedComposeDeploymentRemote(projectName, server, err) } primaryContainerId, err := getPrimaryComposeContainerIdRemote(projectName, server) if err != nil { - log.Warnf("Could not get primary container ID for challenge %s on remote: %v", challengeName, err) - return "", nil // Return empty string but success + return "", cleanupFailedComposeDeploymentRemote(projectName, server, fmt.Errorf("get primary container for challenge %s on remote: %w", challengeName, err)) } log.Debugf("Verified challenge %s services are running on remote. Primary container: %s", challengeName, primaryContainerId) @@ -322,3 +322,10 @@ func ComposePurgeProjectRemote(projectName string, server config.AvailableServer log.Debugf("Successfully purged compose project %s on remote. Output: %s", projectName, purgeOutput) return nil } + +func cleanupFailedComposeDeploymentRemote(projectName string, server config.AvailableServer, deploymentErr error) error { + if cleanupErr := ComposeDownProjectRemote(projectName, server); cleanupErr != nil { + return errors.Join(deploymentErr, fmt.Errorf("clean up failed remote compose deployment: %w", cleanupErr)) + } + return deploymentErr +} diff --git a/pkg/remoteManager/file.go b/pkg/remoteManager/file.go index 2a4f3ad8..a8de84ed 100644 --- a/pkg/remoteManager/file.go +++ b/pkg/remoteManager/file.go @@ -1,13 +1,12 @@ package remoteManager import ( - "bytes" + "context" "fmt" + "os/exec" "path/filepath" "strings" - "os/exec" - "github.com/sdslabs/beastv4/core" "github.com/sdslabs/beastv4/core/config" "github.com/sdslabs/beastv4/core/database" @@ -33,20 +32,25 @@ func RsyncFileToServer(server config.AvailableServer, localFilePath, remoteFileP remoteShell := "ssh -i " + shellQuote(server.SSHKeyPath) + " -o " + shellQuote("UserKnownHostsFile="+server.KnownHostsFile) + " -o StrictHostKeyChecking=yes" - cmd := exec.Command("rsync", "-az", "--protect-args", + ctx, cancel := context.WithTimeout(context.Background(), remoteBuildTimeout) + defer cancel() + cmd := exec.CommandContext(ctx, "rsync", "-az", "--protect-args", "-e", remoteShell, "--", localFilePath, fmt.Sprintf("%s@%s:%s", server.Username, server.Host, remoteFilePath)) - var out bytes.Buffer - var stderr bytes.Buffer - cmd.Stdout = &out - cmd.Stderr = &stderr - err = cmd.Run() - if err != nil { - fmt.Println(fmt.Sprint(err) + ": " + stderr.String()) - return err + output := &boundedCommandOutput{limit: maxRemoteCommandOutput} + cmd.Stdout = output + cmd.Stderr = output + commandErr := cmd.Run() + if ctx.Err() != nil { + return fmt.Errorf("rsync timed out after %s: %w", remoteBuildTimeout, ctx.Err()) + } + if output.exceeded { + return errRemoteOutputLimit + } + if commandErr != nil { + return fmt.Errorf("rsync failed: %w; output: %s", commandErr, output.String()) } - fmt.Println("Result: " + out.String()) return nil } From e75d55542284c86ca758cffe1ca14f32943f09e8 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 14:01:52 +0530 Subject: [PATCH 096/149] fix(resources): reject ineffective container limits Signed-off-by: vibhatsu --- core/config/challenge.go | 3 ++ core/config/config.go | 4 +++ core/config/utils_test.go | 8 +++--- core/manager/static.go | 4 +++ pkg/cr/containers.go | 9 ++++++ pkg/cr/containers_integration_test.go | 4 +++ pkg/cr/images.go | 5 +++- pkg/cr/resources.go | 41 +++++++++++++++++++++++++++ pkg/cr/resources_test.go | 38 +++++++++++++++++++++++++ pkg/remoteManager/container.go | 3 ++ pkg/remoteManager/file.go | 5 +++- 11 files changed, 118 insertions(+), 6 deletions(-) create mode 100644 pkg/cr/resources.go create mode 100644 pkg/cr/resources_test.go diff --git a/core/config/challenge.go b/core/config/challenge.go index 6be918a2..1bea8c99 100644 --- a/core/config/challenge.go +++ b/core/config/challenge.go @@ -648,6 +648,9 @@ func (config *Resources) ValidateRequiredFields() error { log.Debug("CPUsLimit not provided in configuration, using default.") config.CPUsLimit = Cfg.CPUsLimit } + if err := cr.ValidateResourceLimits(config.CPUShares, config.CPUsLimit, config.Memory, config.PidsLimit); err != nil { + return fmt.Errorf("invalid challenge resource limits: %w", err) + } if config.CPUShares > Cfg.CPUShares { return fmt.Errorf("cpu_shares %d exceeds global limit %d", config.CPUShares, Cfg.CPUShares) } diff --git a/core/config/config.go b/core/config/config.go index 0b68fd04..ce3b58fd 100644 --- a/core/config/config.go +++ b/core/config/config.go @@ -14,6 +14,7 @@ import ( "time" "github.com/sdslabs/beastv4/core" + "github.com/sdslabs/beastv4/pkg/cr" "github.com/sdslabs/beastv4/utils" log "github.com/sirupsen/logrus" @@ -336,6 +337,9 @@ func (config *BeastConfig) ValidateConfig() error { log.Debug("Per container CPUsLimit Limit not provided using default value") config.CPUsLimit = core.DEFAULT_CPU_LIMIT } + if err := cr.ValidateResourceLimits(config.CPUShares, config.CPUsLimit, config.Memory, config.PidsLimit); err != nil { + return fmt.Errorf("invalid default container resource limits: %w", err) + } if config.MailConfig.From == "" || config.MailConfig.Password == "" || config.MailConfig.SMTPHost == "" || config.MailConfig.SMTPPort == "" { log.Warn("Mail configuration not provided, email notifications will not work") diff --git a/core/config/utils_test.go b/core/config/utils_test.go index b7fcefa2..42da929d 100644 --- a/core/config/utils_test.go +++ b/core/config/utils_test.go @@ -120,12 +120,12 @@ func TestLoadBeastConfigRejectsSymlink(t *testing.T) { func TestResourcesCannotExceedGlobalLimits(t *testing.T) { previous := Cfg - Cfg = &BeastConfig{CPUShares: 100, Memory: 1024, PidsLimit: 10, CPUsLimit: 1} + Cfg = &BeastConfig{CPUShares: 100, Memory: 8 << 20, PidsLimit: 10, CPUsLimit: 1} defer func() { Cfg = previous }() tests := []Resources{ {CPUShares: 101}, - {Memory: 1025}, + {Memory: (8 << 20) + 1}, {PidsLimit: 11}, {CPUsLimit: 1.1}, } @@ -138,14 +138,14 @@ func TestResourcesCannotExceedGlobalLimits(t *testing.T) { func TestResourcesUseGlobalDefaults(t *testing.T) { previous := Cfg - Cfg = &BeastConfig{CPUShares: 100, Memory: 1024, PidsLimit: 10, CPUsLimit: 1} + Cfg = &BeastConfig{CPUShares: 100, Memory: 8 << 20, PidsLimit: 10, CPUsLimit: 1} defer func() { Cfg = previous }() resources := Resources{} if err := resources.ValidateRequiredFields(); err != nil { t.Fatal(err) } - if resources.CPUShares != 100 || resources.Memory != 1024 || resources.PidsLimit != 10 || resources.CPUsLimit != 1 { + if resources.CPUShares != 100 || resources.Memory != 8<<20 || resources.PidsLimit != 10 || resources.CPUsLimit != 1 { t.Fatalf("unexpected defaults: %+v", resources) } } diff --git a/core/manager/static.go b/core/manager/static.go index a45c3ef8..3ad31d15 100644 --- a/core/manager/static.go +++ b/core/manager/static.go @@ -78,6 +78,10 @@ func DeployStaticContentContainer() error { MountsMap: staticMount, ImageId: imageId, ContainerName: core.BEAST_STATIC_CONTAINER_NAME, + CPUShares: cfg.Cfg.CPUShares, + CPUsLimit: cfg.Cfg.CPUsLimit, + Memory: cfg.Cfg.Memory, + PidsLimit: cfg.Cfg.PidsLimit, } containerId, err := cr.CreateContainerFromImage(&containerConfig) if err != nil { diff --git a/pkg/cr/containers.go b/pkg/cr/containers.go index e81eae14..2011eca6 100644 --- a/pkg/cr/containers.go +++ b/pkg/cr/containers.go @@ -162,6 +162,15 @@ func StopAndRemoveContainer(containerId string) error { } func CreateContainerFromImage(containerConfig *CreateContainerConfig) (string, error) { + if containerConfig == nil { + return "", errors.New("container configuration is required") + } + if containerConfig.ImageId == "" { + return "", errors.New("container image ID is required") + } + if err := ValidateResourceLimits(containerConfig.CPUShares, containerConfig.CPUsLimit, containerConfig.Memory, containerConfig.PidsLimit); err != nil { + return "", fmt.Errorf("invalid container resource limits: %w", err) + } containerName := containerConfig.ContainerName ctx, cancel := context.WithTimeout(context.Background(), dockerAPILongTimeout) defer cancel() diff --git a/pkg/cr/containers_integration_test.go b/pkg/cr/containers_integration_test.go index f48fa24d..1322211b 100644 --- a/pkg/cr/containers_integration_test.go +++ b/pkg/cr/containers_integration_test.go @@ -27,6 +27,10 @@ func TestCreateSearchAndRemoveContainerIntegration(t *testing.T) { ContainerName: containerName, ChallengeName: challengeName, MountsMap: map[string]string{}, + CPUShares: 512, + CPUsLimit: 0.25, + Memory: 512 << 20, + PidsLimit: 100, Labels: map[string]string{ "beast.integration_test": "true", }, diff --git a/pkg/cr/images.go b/pkg/cr/images.go index 84f43e7e..ad69337e 100644 --- a/pkg/cr/images.go +++ b/pkg/cr/images.go @@ -124,6 +124,9 @@ func SearchImageByFilter(filterMap map[string]string) ([]types.ImageSummary, err } func BuildImageFromTarContext(challengeName, challengeTag, tarContextPath, dockerCtxFile string, noCache bool, limits BuildLimits) (*bytes.Buffer, string, error) { + if err := limits.Validate(); err != nil { + return nil, "", fmt.Errorf("invalid build resource limits: %w", err) + } ctx, cancel := context.WithTimeout(context.Background(), buildTimeout) defer cancel() builderContext, err := os.Open(tarContextPath) @@ -139,7 +142,7 @@ func BuildImageFromTarContext(challengeName, challengeTag, tarContextPath, docke NoCache: noCache, CPUShares: limits.CPUShares, CPUPeriod: 100000, - CPUQuota: int64(limits.CPUs * 100000), + CPUQuota: CPUQuota(limits.CPUs), Memory: limits.Memory, MemorySwap: limits.Memory, Ulimits: []*units.Ulimit{{ diff --git a/pkg/cr/resources.go b/pkg/cr/resources.go new file mode 100644 index 00000000..d83798d9 --- /dev/null +++ b/pkg/cr/resources.go @@ -0,0 +1,41 @@ +package cr + +import ( + "fmt" + "math" +) + +const ( + minimumCPULimit float32 = 0.01 + minimumCPUShares int64 = 2 + minimumContainerBytes int64 = 6 << 20 + minimumPidsLimit int64 = 1 +) + +func ValidateResourceLimits(cpuShares int64, cpus float32, memory, pids int64) error { + if cpuShares < minimumCPUShares { + return fmt.Errorf("cpu shares must be at least %d", minimumCPUShares) + } + if math.IsNaN(float64(cpus)) || math.IsInf(float64(cpus), 0) || cpus < minimumCPULimit { + return fmt.Errorf("CPU limit must be finite and at least %.2f", minimumCPULimit) + } + if memory < minimumContainerBytes { + return fmt.Errorf("memory limit must be at least %d bytes", minimumContainerBytes) + } + if pids < minimumPidsLimit { + return fmt.Errorf("PID limit must be at least %d", minimumPidsLimit) + } + return nil +} + +func CPUQuota(cpus float32) int64 { + quota := int64(math.Ceil(float64(cpus) * 100000)) + if quota < 1000 { + return 1000 + } + return quota +} + +func (limits BuildLimits) Validate() error { + return ValidateResourceLimits(limits.CPUShares, limits.CPUs, limits.Memory, limits.Pids) +} diff --git a/pkg/cr/resources_test.go b/pkg/cr/resources_test.go new file mode 100644 index 00000000..4451bbd9 --- /dev/null +++ b/pkg/cr/resources_test.go @@ -0,0 +1,38 @@ +package cr + +import ( + "math" + "testing" +) + +func TestValidateResourceLimitsRejectsUnsafeValues(t *testing.T) { + tests := []struct { + name string + shares int64 + cpus float32 + memory int64 + pids int64 + }{ + {name: "CPU shares", shares: 1, cpus: 1, memory: minimumContainerBytes, pids: 1}, + {name: "CPU quota", shares: 2, cpus: 0.001, memory: minimumContainerBytes, pids: 1}, + {name: "CPU NaN", shares: 2, cpus: float32(math.NaN()), memory: minimumContainerBytes, pids: 1}, + {name: "memory", shares: 2, cpus: 1, memory: minimumContainerBytes - 1, pids: 1}, + {name: "PIDs", shares: 2, cpus: 1, memory: minimumContainerBytes, pids: 0}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if err := ValidateResourceLimits(test.shares, test.cpus, test.memory, test.pids); err == nil { + t.Fatal("expected resource validation error") + } + }) + } +} + +func TestCPUQuotaNeverDisablesLimit(t *testing.T) { + if quota := CPUQuota(0.000001); quota != 1000 { + t.Fatalf("expected minimum quota, got %d", quota) + } + if quota := CPUQuota(0.25); quota != 25000 { + t.Fatalf("expected quarter CPU quota, got %d", quota) + } +} diff --git a/pkg/remoteManager/container.go b/pkg/remoteManager/container.go index b8d48eff..9b0833a5 100644 --- a/pkg/remoteManager/container.go +++ b/pkg/remoteManager/container.go @@ -20,6 +20,9 @@ import ( ) func CreateContainerFromImageRemote(containerConfig cr.CreateContainerConfig, server config.AvailableServer) (string, error) { + if err := cr.ValidateResourceLimits(containerConfig.CPUShares, containerConfig.CPUsLimit, containerConfig.Memory, containerConfig.PidsLimit); err != nil { + return "", fmt.Errorf("invalid container resource limits: %w", err) + } arguments := []string{"docker", "run", "-d", "--cap-drop", "ALL", "--security-opt", "no-new-privileges=true"} if containerConfig.ContainerName != "" { arguments = append(arguments, "--name", containerConfig.ContainerName) diff --git a/pkg/remoteManager/file.go b/pkg/remoteManager/file.go index a8de84ed..e1bef935 100644 --- a/pkg/remoteManager/file.go +++ b/pkg/remoteManager/file.go @@ -74,6 +74,9 @@ func StageChallRemote(server config.AvailableServer, challenge database.Challeng // BuildImageFromTarContextRemote builds a Docker image from the tar context on the remote server. func BuildImageFromTarContextRemote(challengeName string, imageTag string, stagedDir string, server config.AvailableServer, limits cr.BuildLimits) ([]byte, string, error) { + if err := limits.Validate(); err != nil { + return nil, "", fmt.Errorf("invalid build resource limits: %w", err) + } remoteExtractPath := filepath.Join(core.BEAST_REMOTE_GLOBAL_DIR, core.BEAST_STAGING_DIR, challengeName, challengeName) _, err := RunArgsOnServer(server, "mkdir", "-p", remoteExtractPath) if err == nil { @@ -87,7 +90,7 @@ func BuildImageFromTarContextRemote(challengeName string, imageTag string, stage "docker", "build", "-t", imageTag, "--cpu-shares", fmt.Sprintf("%d", limits.CPUShares), "--cpu-period", "100000", - "--cpu-quota", fmt.Sprintf("%d", int64(limits.CPUs*100000)), + "--cpu-quota", fmt.Sprintf("%d", cr.CPUQuota(limits.CPUs)), "--memory", fmt.Sprintf("%d", limits.Memory), "--memory-swap", fmt.Sprintf("%d", limits.Memory), "--ulimit", fmt.Sprintf("nproc=%d:%d", limits.Pids, limits.Pids), From abebc3ed344da0c11bf8061f181038e02e7103df Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 14:06:46 +0530 Subject: [PATCH 097/149] fix(database): fail closed on user records Signed-off-by: vibhatsu --- core/database/authorization_test.go | 19 +++++++++++++++++++ core/database/user.go | 18 +++++------------- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/core/database/authorization_test.go b/core/database/authorization_test.go index 40f94f9e..727598e5 100644 --- a/core/database/authorization_test.go +++ b/core/database/authorization_test.go @@ -1,6 +1,7 @@ package database import ( + "errors" "testing" "gorm.io/gorm" @@ -44,6 +45,24 @@ func TestIsChallengeMaintainer(t *testing.T) { } } +func TestUserQueriesAndCreationFailClosed(t *testing.T) { + cleanup := setupSubmissionTestDB(t) + defer cleanup() + + if _, err := QueryUserById(999999); !errors.Is(err, gorm.ErrRecordNotFound) { + t.Fatalf("expected missing user error, got %v", err) + } + if err := CreateUserEntry(nil); err == nil { + t.Fatal("expected nil user error") + } + + user := createSubmissionTestUser(t, "unique-user") + duplicate := User{Name: "duplicate", Email: user.Email, AuthModel: user.AuthModel} + if err := CreateUserEntry(&duplicate); err == nil { + t.Fatal("expected duplicate user error") + } +} + func TestMigrateChallengeMaintainersPreservesManagers(t *testing.T) { cleanup := setupSubmissionTestDB(t) defer cleanup() diff --git a/core/database/user.go b/core/database/user.go index f117dfad..9ad2c32e 100644 --- a/core/database/user.go +++ b/core/database/user.go @@ -68,7 +68,7 @@ func QueryUserById(authorID uint) (User, error) { tx := Db.First(&user, authorID) if errors.Is(tx.Error, gorm.ErrRecordNotFound) { - return User{}, nil + return User{}, gorm.ErrRecordNotFound } return user, tx.Error @@ -114,20 +114,12 @@ func QueryFirstUserEntry(key string, value string) (User, error) { // It returns an error if anything wrong happen during the // transaction. func CreateUserEntry(user *User) error { + if user == nil { + return errors.New("user is required") + } DBMux.Lock() defer DBMux.Unlock() - tx := Db.Begin() - - if tx.Error != nil { - return fmt.Errorf("Error while starting transaction: %v", tx.Error) - } - - if err := tx.FirstOrCreate(user, *user).Error; err != nil { - tx.Rollback() - return err - } - - return tx.Commit().Error + return Db.Create(user).Error } // Update an entry for the user in the User table From c7566a7acbaaefa58d54fbd7846d9d9fb1f4f30d Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 14:08:25 +0530 Subject: [PATCH 098/149] fix(api): distinguish missing user records Signed-off-by: vibhatsu --- api/admin.go | 6 ++++++ api/info.go | 18 +++++++++++++++--- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/api/admin.go b/api/admin.go index 20d9c3f9..0b6fc7e8 100644 --- a/api/admin.go +++ b/api/admin.go @@ -1,6 +1,7 @@ package api import ( + "errors" "fmt" "net/http" "strconv" @@ -8,6 +9,7 @@ import ( "github.com/gin-gonic/gin" "github.com/sdslabs/beastv4/core" "github.com/sdslabs/beastv4/core/database" + "gorm.io/gorm" ) // Ban/Unban/Hide/Unhide a user based on his id and the action provided. @@ -55,6 +57,10 @@ func userActionHandler(c *gin.Context) { user, err := database.QueryUserById(uint(parsedUserId)) if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, HTTPPlainResp{Message: "User not found."}) + return + } c.JSON(http.StatusInternalServerError, HTTPPlainResp{ Message: "DATABASE ERROR while processing the request.", }) diff --git a/api/info.go b/api/info.go index 2c62f089..73e1db72 100644 --- a/api/info.go +++ b/api/info.go @@ -1,6 +1,7 @@ package api import ( + "errors" "fmt" "net/http" "path/filepath" @@ -18,6 +19,7 @@ import ( "github.com/sdslabs/beastv4/pkg/auth" fileUtils "github.com/sdslabs/beastv4/utils" log "github.com/sirupsen/logrus" + "gorm.io/gorm" ) var ( @@ -532,6 +534,10 @@ func userInfoHandler(c *gin.Context) { user, err = database.QueryUserById(uint(id)) if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, HTTPErrorResp{Error: "User not found"}) + return + } c.JSON(http.StatusInternalServerError, HTTPErrorResp{ Error: "DATABASE ERROR while processing the request.", }) @@ -540,6 +546,10 @@ func userInfoHandler(c *gin.Context) { } else { user, err = database.QueryFirstUserEntry("username", username) if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, HTTPErrorResp{Error: "User not found"}) + return + } c.JSON(http.StatusInternalServerError, HTTPErrorResp{ Error: "DATABASE ERROR while processing the request.", }) @@ -1409,9 +1419,11 @@ func getUserAttempts(c *gin.Context) { submissionUser, err := database.QueryUserById(uint(userID)) if err != nil { - c.JSON(http.StatusNotFound, HTTPErrorResp{ - Error: "User not found", - }) + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, HTTPErrorResp{Error: "User not found"}) + } else { + c.JSON(http.StatusInternalServerError, HTTPErrorResp{Error: "DATABASE ERROR while processing the request."}) + } return } From 4c006ad1f3a637f199b7dd921cfd94d90f67080d Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 14:09:02 +0530 Subject: [PATCH 099/149] fix(secrets): redact flags from webhooks Signed-off-by: vibhatsu --- api/submit.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/api/submit.go b/api/submit.go index e977438e..1334f44a 100644 --- a/api/submit.go +++ b/api/submit.go @@ -2,6 +2,7 @@ package api import ( "context" + "fmt" "math" "net/http" "strconv" @@ -197,8 +198,13 @@ func submitFlagHandler(c *gin.Context) { return } if claim.Status == database.DynamicFlagClaimedByOtherUser { - subuser, _ := database.QueryUserById(claim.ClaimedByID) - msg := "User " + user.Username + " has submitted the flag " + flag + " for challenge " + challenge.Name + " which has already been claimed by user " + subuser.Username + claimedBy := fmt.Sprintf("ID %d", claim.ClaimedByID) + if subuser, lookupErr := database.QueryUserById(claim.ClaimedByID); lookupErr != nil { + log.Warnf("failed to resolve dynamic flag claimant %d: %v", claim.ClaimedByID, lookupErr) + } else { + claimedBy = subuser.Username + } + msg := fmt.Sprintf("User %s submitted a duplicate dynamic flag for challenge %s already claimed by user %s", user.Username, challenge.Name, claimedBy) go notify.SendNotification(notify.Warning, msg) if err := database.MarkSubmissionCheating(user.ID, challenge.ID, flag); err != nil { log.Warnf("failed to mark duplicate dynamic flag submission as cheating: %v", err) From 99100bb8089c622198f80b41ed5571ed507cc234 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 14:10:35 +0530 Subject: [PATCH 100/149] fix(database): make challenge lookup explicit Signed-off-by: vibhatsu --- api/config.go | 6 ++++++ api/exec.go | 9 +++++---- core/database/challenges.go | 17 ++++++++++++++--- core/manager/pipeline.go | 2 +- core/manager/utils.go | 2 +- core/utils/cleanup.go | 4 ++-- core/utils/command_utils.go | 4 ++-- 7 files changed, 31 insertions(+), 13 deletions(-) diff --git a/api/config.go b/api/config.go index 4b1dbcbe..3e1642a3 100644 --- a/api/config.go +++ b/api/config.go @@ -1,6 +1,7 @@ package api import ( + "errors" "fmt" "net/http" "strconv" @@ -10,6 +11,7 @@ import ( "github.com/sdslabs/beastv4/core" "github.com/sdslabs/beastv4/core/database" log "github.com/sirupsen/logrus" + "gorm.io/gorm" ) // This updates challenge info in the respective challenge configuration @@ -70,6 +72,10 @@ func updateChallengeInfoHandler(c *gin.Context) { log.Debug(fmt.Sprintf("Starting to update the challenge : %s", name)) chall, err := database.QueryFirstChallengeEntry("name", name) if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, HTTPErrorResp{Error: "Challenge not found"}) + return + } log.Errorf("DB_ACCESS_ERROR : %s", err.Error()) c.JSON(http.StatusInternalServerError, HTTPErrorResp{ Error: fmt.Sprintf("DB_ACCESS_ERROR : %s", err.Error()), diff --git a/api/exec.go b/api/exec.go index 1bb6a0ff..34b6de49 100644 --- a/api/exec.go +++ b/api/exec.go @@ -16,6 +16,7 @@ import ( "github.com/sdslabs/beastv4/pkg/auth" "github.com/sdslabs/beastv4/pkg/cr" "github.com/sdslabs/beastv4/pkg/remoteManager" + "gorm.io/gorm" ) const ( @@ -103,13 +104,13 @@ func execChallengeHandler(c *gin.Context) { } challenge, err := database.QueryFirstChallengeEntry("name", c.Param("name")) if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, HTTPErrorResp{Error: "challenge not found"}) + return + } c.JSON(http.StatusInternalServerError, HTTPErrorResp{Error: "failed to query challenge"}) return } - if challenge.ID == 0 { - c.JSON(http.StatusNotFound, HTTPErrorResp{Error: "challenge not found"}) - return - } maintainer, err := database.IsChallengeMaintainer(user.ID, challenge.ID) if err != nil { c.JSON(http.StatusInternalServerError, HTTPErrorResp{Error: "failed to authorize challenge access"}) diff --git a/core/database/challenges.go b/core/database/challenges.go index 3475acea..884ca244 100644 --- a/core/database/challenges.go +++ b/core/database/challenges.go @@ -258,16 +258,27 @@ func QueryChallengeEntriesMap(whereMap map[string]interface{}) ([]Challenge, err // Using the column value in key and value in value get the first // result of the query. func QueryFirstChallengeEntry(key string, value string) (Challenge, error) { - challenges, err := QueryChallengeEntries(key, value) + challenge, found, err := FindFirstChallengeEntry(key, value) if err != nil { return Challenge{}, err } + if !found { + return Challenge{}, gorm.ErrRecordNotFound + } + return challenge, nil +} + +func FindFirstChallengeEntry(key string, value string) (Challenge, bool, error) { + challenges, err := QueryChallengeEntries(key, value) + if err != nil { + return Challenge{}, false, err + } if len(challenges) == 0 { - return Challenge{}, nil + return Challenge{}, false, nil } - return challenges[0], nil + return challenges[0], true, nil } // Check Pre Reqs Status diff --git a/core/manager/pipeline.go b/core/manager/pipeline.go index 5cd1318e..f85fbeb0 100644 --- a/core/manager/pipeline.go +++ b/core/manager/pipeline.go @@ -476,7 +476,7 @@ func bootstrapDeployPipeline(challengeDir string, skipStage bool, skipCommit boo return fmt.Errorf("CONFIG ERROR: %s : Inconsistent configuration name and challengeName", challengeName) } - challenge, err := database.QueryFirstChallengeEntry("name", config.Challenge.Metadata.Name) + challenge, _, err := database.FindFirstChallengeEntry("name", config.Challenge.Metadata.Name) if err != nil { log.Errorf("Error while querying challenge %s : %s", config.Challenge.Metadata.Name, err) return fmt.Errorf("DB ERROR: %s : %s", challengeName, err) diff --git a/core/manager/utils.go b/core/manager/utils.go index 144b39f8..fe50b6f4 100644 --- a/core/manager/utils.go +++ b/core/manager/utils.go @@ -981,7 +981,7 @@ func UpdateChallenges(defaultauthorpassword string) { continue } - challenge, err := database.QueryFirstChallengeEntry("name", config.Challenge.Metadata.Name) + challenge, _, err := database.FindFirstChallengeEntry("name", config.Challenge.Metadata.Name) if err != nil { log.Errorf("Error while querying challenge %s : %s", config.Challenge.Metadata.Name, err) continue diff --git a/core/utils/cleanup.go b/core/utils/cleanup.go index dc849fe7..dc16c7d8 100644 --- a/core/utils/cleanup.go +++ b/core/utils/cleanup.go @@ -124,13 +124,13 @@ func CleanupChallengeImage(chall *database.Challenge) error { } func CleanupChallengeIfExist(config cfg.BeastChallengeConfig) error { - chall, err := database.QueryFirstChallengeEntry("name", config.Challenge.Metadata.Name) + chall, found, err := database.FindFirstChallengeEntry("name", config.Challenge.Metadata.Name) if err != nil { log.Errorf("Error while database query for challenge %s", config.Challenge.Metadata.Name) return err } - if chall.Name == "" { + if !found { log.Info("No such challenge exist in the database") return nil } diff --git a/core/utils/command_utils.go b/core/utils/command_utils.go index 7b1b5037..d16eb8a9 100644 --- a/core/utils/command_utils.go +++ b/core/utils/command_utils.go @@ -26,11 +26,11 @@ func CreateAdminOrAuthor(name string, username string, email string, password st } func DeleteChallengeEntryWithPorts(challname string) error { - chall, err := database.QueryFirstChallengeEntry("name", challname) + chall, found, err := database.FindFirstChallengeEntry("name", challname) if err != nil { return fmt.Errorf("Error while querying database : %v", err) } - if chall.Name == "" { + if !found { return nil } ports, err := database.GetAllocatedPorts(chall) From 790b8eda78493559d6c081c9c0f346fa94a98d4b Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 14:11:39 +0530 Subject: [PATCH 101/149] fix(database): constrain generic query columns Signed-off-by: vibhatsu --- core/database/challenges.go | 6 +++++- core/database/notification.go | 6 +++++- core/database/query.go | 16 ++++++++++++++++ core/database/query_test.go | 12 ++++++++++++ core/database/user.go | 6 +++++- pkg/remoteManager/container.go | 2 +- 6 files changed, 44 insertions(+), 4 deletions(-) create mode 100644 core/database/query.go create mode 100644 core/database/query_test.go diff --git a/core/database/challenges.go b/core/database/challenges.go index 884ca244..621b3548 100644 --- a/core/database/challenges.go +++ b/core/database/challenges.go @@ -190,7 +190,11 @@ func QueryAllChallengesMetadata() ([]Challenge, error) { // Queries all the challenges entries where the column represented by key // have the value in value. func QueryChallengeEntries(key string, value string) ([]Challenge, error) { - queryKey := fmt.Sprintf("%s = ?", key) + column, err := validatedQueryColumn(key, "id", "name", "status", "container_id") + if err != nil { + return nil, err + } + queryKey := fmt.Sprintf("%s = ?", column) var challenges []Challenge diff --git a/core/database/notification.go b/core/database/notification.go index 584fc290..4c360bd7 100644 --- a/core/database/notification.go +++ b/core/database/notification.go @@ -57,7 +57,11 @@ func DeleteNotification(notification *Notification) error { // Queries all the challenges entries where the column represented by key // have the value in value. func QueryNotificationEntries(key string, value string) ([]Notification, error) { - queryKey := fmt.Sprintf("%s = ?", key) + column, err := validatedQueryColumn(key, "id") + if err != nil { + return nil, err + } + queryKey := fmt.Sprintf("%s = ?", column) var notifications []Notification diff --git a/core/database/query.go b/core/database/query.go new file mode 100644 index 00000000..bb7677ce --- /dev/null +++ b/core/database/query.go @@ -0,0 +1,16 @@ +package database + +import ( + "fmt" + "strings" +) + +func validatedQueryColumn(key string, allowed ...string) (string, error) { + column := strings.ToLower(strings.TrimSpace(key)) + for _, candidate := range allowed { + if column == candidate { + return column, nil + } + } + return "", fmt.Errorf("unsupported query column %q", key) +} diff --git a/core/database/query_test.go b/core/database/query_test.go new file mode 100644 index 00000000..2eb9809b --- /dev/null +++ b/core/database/query_test.go @@ -0,0 +1,12 @@ +package database + +import "testing" + +func TestValidatedQueryColumnRejectsSQLFragments(t *testing.T) { + if column, err := validatedQueryColumn("ID", "id", "name"); err != nil || column != "id" { + t.Fatalf("expected normalized ID column, got %q, %v", column, err) + } + if _, err := validatedQueryColumn("id OR 1=1", "id", "name"); err == nil { + t.Fatal("expected SQL fragment to be rejected") + } +} diff --git a/core/database/user.go b/core/database/user.go index 9ad2c32e..ddbfe055 100644 --- a/core/database/user.go +++ b/core/database/user.go @@ -27,7 +27,11 @@ type User struct { // Queries all the users entries where the column represented by key // have the value in value. func QueryUserEntries(key string, value string) ([]User, error) { - queryKey := fmt.Sprintf("%s = ?", key) + column, err := validatedQueryColumn(key, "id", "username", "email", "role") + if err != nil { + return nil, err + } + queryKey := fmt.Sprintf("%s = ?", column) var users []User DBMux.Lock() diff --git a/pkg/remoteManager/container.go b/pkg/remoteManager/container.go index 9b0833a5..46d4a080 100644 --- a/pkg/remoteManager/container.go +++ b/pkg/remoteManager/container.go @@ -87,7 +87,7 @@ func CreateContainerFromImageRemote(containerConfig cr.CreateContainerConfig, se // else just take containerID and find the server config from db func StopAndRemoveContainerRemote(containerId string, server config.AvailableServer) error { if server == (config.AvailableServer{}) { - chall, err := database.QueryChallengeEntries("id", containerId) + chall, err := database.QueryChallengeEntries("container_id", containerId) if err != nil { if err == (gorm.ErrRecordNotFound) { log.Debugf("no container with container id %s present", containerId) From cd3cf2bee2caa3ca75960c469522d8d7197348a7 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 14:14:46 +0530 Subject: [PATCH 102/149] fix(webhooks): prevent internal network access Signed-off-by: vibhatsu --- core/config/config.go | 34 +++++++++++++++++++++ core/config/utils_test.go | 24 +++++++++++++++ pkg/notify/discord.go | 18 +---------- pkg/notify/http.go | 63 +++++++++++++++++++++++++++++++++++++++ pkg/notify/http_test.go | 17 +++++++++++ pkg/notify/main.go | 16 +++++++--- pkg/notify/slack.go | 18 +---------- 7 files changed, 152 insertions(+), 38 deletions(-) create mode 100644 pkg/notify/http.go create mode 100644 pkg/notify/http_test.go diff --git a/core/config/config.go b/core/config/config.go index ce3b58fd..81a0d346 100644 --- a/core/config/config.go +++ b/core/config/config.go @@ -299,6 +299,11 @@ func (config *BeastConfig) ValidateConfig() error { } } } + for index := range config.NotificationWebhooks { + if err := config.NotificationWebhooks[index].Validate(); err != nil { + return fmt.Errorf("validate notification webhook %d: %w", index+1, err) + } + } if config.TickerFrequency <= 0 { log.Debug("Time is not provided or is less than equal to zero so default time is taken") @@ -532,6 +537,35 @@ type NotificationWebhook struct { Active bool `toml:"active"` } +func (webhook NotificationWebhook) Validate() error { + if !webhook.Active { + return nil + } + if len(webhook.URL) > 2048 { + return errors.New("webhook URL is too long") + } + parsed, err := url.Parse(webhook.URL) + if err != nil || parsed.Scheme != "https" || parsed.Hostname() == "" || parsed.User != nil || parsed.Fragment != "" { + return errors.New("active webhook must use a valid HTTPS URL without credentials or fragments") + } + if parsed.Port() != "" && parsed.Port() != "443" { + return errors.New("webhook URL may only use the default HTTPS port") + } + switch webhook.ServiceName { + case "slack": + if parsed.Hostname() != "hooks.slack.com" || !strings.HasPrefix(parsed.EscapedPath(), "/services/") { + return errors.New("Slack webhook must use hooks.slack.com/services") + } + case "discord": + if (parsed.Hostname() != "discord.com" && parsed.Hostname() != "discordapp.com") || !strings.HasPrefix(parsed.EscapedPath(), "/api/webhooks/") { + return errors.New("Discord webhook must use the official API webhook endpoint") + } + default: + return fmt.Errorf("unsupported notification service %q", webhook.ServiceName) + } + return nil +} + type CompetitionInfo struct { Name string `toml:"name"` About string `toml:"about"` diff --git a/core/config/utils_test.go b/core/config/utils_test.go index 42da929d..7c335b12 100644 --- a/core/config/utils_test.go +++ b/core/config/utils_test.go @@ -176,6 +176,30 @@ func TestExampleGlobalConfigHasNoUnknownKeys(t *testing.T) { } } +func TestNotificationWebhookValidation(t *testing.T) { + valid := []NotificationWebhook{ + {Active: true, ServiceName: "slack", URL: "https://hooks.slack.com/services/a/b/c"}, + {Active: true, ServiceName: "discord", URL: "https://discord.com/api/webhooks/1/token"}, + } + for _, webhook := range valid { + if err := webhook.Validate(); err != nil { + t.Fatalf("expected valid webhook: %v", err) + } + } + + invalid := []NotificationWebhook{ + {Active: true, ServiceName: "slack", URL: "http://hooks.slack.com/services/a/b/c"}, + {Active: true, ServiceName: "slack", URL: "https://127.0.0.1/services/a"}, + {Active: true, ServiceName: "discord", URL: "https://discord.com.evil.test/api/webhooks/1/token"}, + {Active: true, ServiceName: "custom", URL: "https://example.com/hook"}, + } + for _, webhook := range invalid { + if err := webhook.Validate(); err == nil { + t.Fatalf("expected invalid webhook to fail: %+v", webhook) + } + } +} + func TestExampleChallengeConfigsHaveNoUnknownKeys(t *testing.T) { paths, err := filepath.Glob(filepath.Join("..", "..", "_examples", "*", "beast.toml")) if err != nil { diff --git a/pkg/notify/discord.go b/pkg/notify/discord.go index 1f8d24f3..4d7ada5e 100644 --- a/pkg/notify/discord.go +++ b/pkg/notify/discord.go @@ -1,10 +1,8 @@ package notify import ( - "bytes" "encoding/json" "fmt" - "net/http" "time" ) @@ -50,19 +48,5 @@ func (d *DiscordNotificationProvider) SendNotification(nType NotificationType, m return fmt.Errorf("Error while converting payload to JSON : %s", err) } - payloadReader := bytes.NewReader(payload) - req, err := http.NewRequest("POST", d.Request.WebHookURL, payloadReader) - if err != nil { - return fmt.Errorf("Error while connecting to webhook url host : %s", err) - } - - req.Header.Set("Content-Type", "application/json") - client := http.Client{} - _, err = client.Do(req) - - if err != nil { - return fmt.Errorf("Error while posting payload for notification : %s", err) - } - - return nil + return postWebhookJSON(d.Request.WebHookURL, payload) } diff --git a/pkg/notify/http.go b/pkg/notify/http.go new file mode 100644 index 00000000..66d6f618 --- /dev/null +++ b/pkg/notify/http.go @@ -0,0 +1,63 @@ +package notify + +import ( + "bytes" + "context" + "crypto/tls" + "fmt" + "io" + "net" + "net/http" + "time" +) + +var webhookHTTPClient = &http.Client{ + Timeout: 10 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}, + DialContext: dialPublicAddress, + }, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + }, +} + +func dialPublicAddress(ctx context.Context, network, address string) (net.Conn, error) { + host, port, err := net.SplitHostPort(address) + if err != nil { + return nil, fmt.Errorf("split webhook address: %w", err) + } + addresses, err := net.DefaultResolver.LookupIPAddr(ctx, host) + if err != nil { + return nil, fmt.Errorf("resolve webhook host: %w", err) + } + for _, address := range addresses { + if !isPublicIP(address.IP) { + continue + } + return (&net.Dialer{Timeout: 5 * time.Second}).DialContext(ctx, network, net.JoinHostPort(address.IP.String(), port)) + } + return nil, fmt.Errorf("webhook host has no public IP addresses") +} + +func isPublicIP(ip net.IP) bool { + return ip != nil && !ip.IsPrivate() && !ip.IsLoopback() && !ip.IsLinkLocalUnicast() && !ip.IsLinkLocalMulticast() && !ip.IsUnspecified() && !ip.IsMulticast() +} + +func postWebhookJSON(webhookURL string, payload []byte) error { + req, err := http.NewRequest(http.MethodPost, webhookURL, bytes.NewReader(payload)) + if err != nil { + return fmt.Errorf("create webhook request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + response, err := webhookHTTPClient.Do(req) + if err != nil { + return fmt.Errorf("post webhook payload: %w", err) + } + defer response.Body.Close() + _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4096)) + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { + return fmt.Errorf("webhook returned HTTP status %d", response.StatusCode) + } + return nil +} diff --git a/pkg/notify/http_test.go b/pkg/notify/http_test.go new file mode 100644 index 00000000..ccc05387 --- /dev/null +++ b/pkg/notify/http_test.go @@ -0,0 +1,17 @@ +package notify + +import ( + "net" + "testing" +) + +func TestIsPublicIPRejectsInternalDestinations(t *testing.T) { + for _, address := range []string{"127.0.0.1", "10.0.0.1", "169.254.169.254", "::1", "fe80::1"} { + if isPublicIP(net.ParseIP(address)) { + t.Fatalf("expected %s to be rejected", address) + } + } + if !isPublicIP(net.ParseIP("8.8.8.8")) { + t.Fatal("expected public address to be accepted") + } +} diff --git a/pkg/notify/main.go b/pkg/notify/main.go index d8959b0d..cbed9c4f 100644 --- a/pkg/notify/main.go +++ b/pkg/notify/main.go @@ -1,6 +1,7 @@ package notify import ( + "errors" "fmt" "net/url" "strings" @@ -58,8 +59,6 @@ const ( // In the Discord notification provider it was using the same payload which was used for slack. // By writing "/slack" in the discord WebHookURL, it execute Slack-Compatible Webhook func NewNotifier(url *url.URL, ProviderType ProviderTypeEnum) Notifier { - log.Debug("Inside notifier: Webhook URL: " + url.String()) - switch ProviderType { case SlackProvider: return &SlackNotificationProvider{ @@ -68,9 +67,11 @@ func NewNotifier(url *url.URL, ProviderType ProviderTypeEnum) Notifier { }, } case DiscordProvider: + discordURL := *url + discordURL.Path = strings.TrimSuffix(discordURL.Path, "/") + "/slack" return &DiscordNotificationProvider{ Request{ - WebHookURL: url.String() + "/slack", + WebHookURL: discordURL.String(), }, } } @@ -88,9 +89,16 @@ func (req *Request) FillReqParams() error { func SendNotification(nType NotificationType, message string) error { var errs []string + if len(message) > 16<<10 { + message = message[:16<<10] + } for _, webhook := range config.Cfg.NotificationWebhooks { if webhook.ServiceName != "" && webhook.URL != "" && webhook.Active { + if err := webhook.Validate(); err != nil { + errs = append(errs, fmt.Sprintf("invalid %s webhook configuration: %s", webhook.ServiceName, err)) + continue + } var provider ProviderTypeEnum url, err := url.ParseRequestURI(webhook.URL) @@ -132,5 +140,5 @@ func SendNotification(nType NotificationType, message string) error { return nil } - return fmt.Errorf(strings.Join(errs, "\n")) + return errors.New(strings.Join(errs, "\n")) } diff --git a/pkg/notify/slack.go b/pkg/notify/slack.go index 9151cb56..318b052f 100644 --- a/pkg/notify/slack.go +++ b/pkg/notify/slack.go @@ -1,10 +1,8 @@ package notify import ( - "bytes" "encoding/json" "fmt" - "net/http" "time" ) @@ -49,19 +47,5 @@ func (s *SlackNotificationProvider) SendNotification(nType NotificationType, msg return fmt.Errorf("Error while converting payload to JSON : %s", err) } - payloadReader := bytes.NewReader(payload) - req, err := http.NewRequest("POST", s.Request.WebHookURL, payloadReader) - if err != nil { - return fmt.Errorf("Error while connecting to webhook url host : %s", err) - } - - req.Header.Set("Content-Type", "application/json") - client := http.Client{} - _, err = client.Do(req) - - if err != nil { - return fmt.Errorf("Error while posting payload for notification : %s", err) - } - - return nil + return postWebhookJSON(s.Request.WebHookURL, payload) } From 80bb3507e420c7932f868ca4a3cd738658593f58 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 14:19:14 +0530 Subject: [PATCH 103/149] feat(redis): secure remote connections with TLS Signed-off-by: vibhatsu --- _examples/example.config.toml | 7 +- api/main.go | 2 +- cmd/beast/init.go | 29 +++++++-- core/cache/cache.go | 118 ++++++++++++++++++++++++---------- core/cache/cache_test.go | 25 +++++++ core/config/config.go | 36 +++++++++-- core/config/utils_test.go | 11 ++++ 7 files changed, 181 insertions(+), 47 deletions(-) diff --git a/_examples/example.config.toml b/_examples/example.config.toml index 140f5b98..97398fc8 100644 --- a/_examples/example.config.toml +++ b/_examples/example.config.toml @@ -113,8 +113,11 @@ sslmode = "prefer" [redis_config] host = "localhost" port = "6379" -password = "" -user = "" +password = "CHANGE_ME" +user = "beast" +tls = false +ca_file = "" +server_name = "" [instance_config] default_expiration = 300 diff --git a/api/main.go b/api/main.go index f6c7d37c..747e8d63 100644 --- a/api/main.go +++ b/api/main.go @@ -103,7 +103,7 @@ func RunBeastApiServer(ctx context.Context, port, defaultauthorpassword string, if err := database.Init(); err != nil { return err } - cache.Configure(config.Cfg.RedisConf.User, config.Cfg.RedisConf.Password, config.Cfg.RedisConf.Host, config.Cfg.RedisConf.Port, config.Cfg.RedisConf.Db) + cache.Configure(config.Cfg.RedisConf.User, config.Cfg.RedisConf.Password, config.Cfg.RedisConf.Host, config.Cfg.RedisConf.Port, config.Cfg.RedisConf.Db, config.Cfg.RedisConf.TLS, config.Cfg.RedisConf.CAFile, config.Cfg.RedisConf.ServerName) if err := cache.Init(); err != nil { return err } diff --git a/cmd/beast/init.go b/cmd/beast/init.go index ee536aa3..a07d3019 100644 --- a/cmd/beast/init.go +++ b/cmd/beast/init.go @@ -6,17 +6,20 @@ import ( "errors" "fmt" "io" + "net" "net/http" "os" "os/exec" "os/user" "path/filepath" "strings" + "time" _ "github.com/jackc/pgx/v5/stdlib" "github.com/lib/pq" "github.com/redis/go-redis/v9" "github.com/sdslabs/beastv4/core" + coreCache "github.com/sdslabs/beastv4/core/cache" "github.com/sdslabs/beastv4/core/config" "github.com/sdslabs/beastv4/core/database" coreUtils "github.com/sdslabs/beastv4/core/utils" @@ -130,21 +133,35 @@ func initCache() error { log.Infoln("Initializing cache...") redisConfig := config.Cfg.RedisConf + tlsConfig, err := coreCache.NewTLSConfig(redisConfig.TLS, redisConfig.CAFile, redisConfig.ServerName, redisConfig.Host) + if err != nil { + return err + } var cache *redis.Client if utils.PromptBinary("Do you use password authentication for the redis default user?") { cache = redis.NewClient(&redis.Options{ - Addr: fmt.Sprintf("%s:%s", redisConfig.Host, redisConfig.Port), - Username: core.REDIS_DEFAULT_USER, - Password: utils.PromptSecret("Enter default redis user password"), + Addr: net.JoinHostPort(redisConfig.Host, redisConfig.Port), + Username: core.REDIS_DEFAULT_USER, + Password: utils.PromptSecret("Enter default redis user password"), + TLSConfig: tlsConfig, + DialTimeout: 5 * time.Second, + ReadTimeout: 5 * time.Second, + WriteTimeout: 5 * time.Second, }) } else { cache = redis.NewClient(&redis.Options{ - Addr: fmt.Sprintf("%s:%s", redisConfig.Host, redisConfig.Port), - Username: core.REDIS_DEFAULT_USER, + Addr: net.JoinHostPort(redisConfig.Host, redisConfig.Port), + Username: core.REDIS_DEFAULT_USER, + TLSConfig: tlsConfig, + DialTimeout: 5 * time.Second, + ReadTimeout: 5 * time.Second, + WriteTimeout: 5 * time.Second, }) } - _, err := cache.Ping(context.Background()).Result() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, err = cache.Ping(ctx).Result() if err != nil { return fmt.Errorf("failed to connected to redis: %s", err.Error()) } diff --git a/core/cache/cache.go b/core/cache/cache.go index 6f56b2fe..564b1338 100644 --- a/core/cache/cache.go +++ b/core/cache/cache.go @@ -2,6 +2,8 @@ package cache import ( "context" + "crypto/tls" + "crypto/x509" "fmt" "net" "os" @@ -26,15 +28,18 @@ var ( var cacheConfig RedisConfig type RedisConfig struct { - User string - Password string - Host string - Port string - DB uint32 + User string + Password string + Host string + Port string + DB uint32 + TLS bool + CAFile string + ServerName string } -func Configure(user, password, host, port string, db uint32) { - cacheConfig = RedisConfig{User: user, Password: password, Host: host, Port: port, DB: db} +func Configure(user, password, host, port string, db uint32, tlsEnabled bool, caFile, serverName string) { + cacheConfig = RedisConfig{User: user, Password: password, Host: host, Port: port, DB: db, TLS: tlsEnabled, CAFile: caFile, ServerName: serverName} } func LoadCacheConfig() error { @@ -48,21 +53,55 @@ func redisAddress(config RedisConfig) string { return net.JoinHostPort(config.Host, config.Port) } +func NewTLSConfig(enabled bool, caFile, serverName, host string) (*tls.Config, error) { + if !enabled { + return nil, nil + } + if serverName == "" { + serverName = host + } + tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12, ServerName: serverName} + if caFile == "" { + return tlsConfig, nil + } + certificate, err := os.ReadFile(caFile) + if err != nil { + return nil, fmt.Errorf("read Redis CA file: %w", err) + } + roots, err := x509.SystemCertPool() + if err != nil || roots == nil { + roots = x509.NewCertPool() + } + if !roots.AppendCertsFromPEM(certificate) { + return nil, fmt.Errorf("Redis CA file contains no certificates") + } + tlsConfig.RootCAs = roots + return tlsConfig, nil +} + // Connect redis func ConnectCache() error { if err := LoadCacheConfig(); err != nil { return err } + tlsConfig, err := NewTLSConfig(cacheConfig.TLS, cacheConfig.CAFile, cacheConfig.ServerName, cacheConfig.Host) + if err != nil { + return err + } client := redis.NewClient(&redis.Options{ - Addr: redisAddress(cacheConfig), - Username: cacheConfig.User, - Password: cacheConfig.Password, - DB: int(cacheConfig.DB), + Addr: redisAddress(cacheConfig), + Username: cacheConfig.User, + Password: cacheConfig.Password, + DB: int(cacheConfig.DB), + TLSConfig: tlsConfig, + DialTimeout: 5 * time.Second, + ReadTimeout: 5 * time.Second, + WriteTimeout: 5 * time.Second, }) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - _, err := client.Ping(ctx).Result() + _, err = client.Ping(ctx).Result() if err != nil { _ = client.Close() return fmt.Errorf("connect to Redis: %w", err) @@ -230,15 +269,8 @@ func BackupCache() error { backupFile := fmt.Sprintf("%d_%s.bak", cacheConfig.DB, time.Now().Format("20060102150405")) - args := []string{ - "-h", cacheConfig.Host, - "-p", cacheConfig.Port, - "-n", strconv.FormatUint(uint64(cacheConfig.DB), 10), - "--rdb", filepath.Join(backupPath, backupFile), - } - if cacheConfig.User != "" { - args = append(args, "--user", cacheConfig.User) - } + args := redisCLIConnectionArgs() + args = append(args, "--rdb", filepath.Join(backupPath, backupFile)) cmd := exec.Command("redis-cli", args...) @@ -264,14 +296,8 @@ func ResetCache() error { return err } - dropCmd := exec.Command( - "redis-cli", - "-h", cacheConfig.Host, - "-p", cacheConfig.Port, - "--user", cacheConfig.User, - "-n", strconv.FormatUint(uint64(cacheConfig.DB), 10), - "FLUSHDB", - ) + args := append(redisCLIConnectionArgs(), "FLUSHDB") + dropCmd := exec.Command("redis-cli", args...) dropCmd.Env = append(os.Environ(), fmt.Sprintf("REDISCLI_AUTH=%s", cacheConfig.Password)) @@ -293,13 +319,18 @@ func TerminateCacheConnections() error { } } + tlsConfig, err := NewTLSConfig(cacheConfig.TLS, cacheConfig.CAFile, cacheConfig.ServerName, cacheConfig.Host) + if err != nil { + return err + } cache := redis.NewClient(&redis.Options{ - Addr: redisAddress(cacheConfig), - Username: core.REDIS_DEFAULT_USER, - Password: utils.PromptSecret("Enter default redis user password"), + Addr: redisAddress(cacheConfig), + Username: core.REDIS_DEFAULT_USER, + Password: utils.PromptSecret("Enter default redis user password"), + TLSConfig: tlsConfig, }) - _, err := cache.Ping(context.Background()).Result() + _, err = cache.Ping(context.Background()).Result() if err != nil { log.Errorf("Terminate connections error: %s\n", err.Error()) } @@ -319,6 +350,27 @@ func TerminateCacheConnections() error { return nil } +func redisCLIConnectionArgs() []string { + args := []string{ + "-h", cacheConfig.Host, + "-p", cacheConfig.Port, + "-n", strconv.FormatUint(uint64(cacheConfig.DB), 10), + } + if cacheConfig.User != "" { + args = append(args, "--user", cacheConfig.User) + } + if cacheConfig.TLS { + args = append(args, "--tls") + if cacheConfig.CAFile != "" { + args = append(args, "--cacert", cacheConfig.CAFile) + } + if cacheConfig.ServerName != "" { + args = append(args, "--sni", cacheConfig.ServerName) + } + } + return args +} + func RestoreCache(backupFile string) error { /* The primary issue with restoring cache is that it needs to be written to /var/lib and redis needs to be restarted. diff --git a/core/cache/cache_test.go b/core/cache/cache_test.go index d1683bc7..07eb740c 100644 --- a/core/cache/cache_test.go +++ b/core/cache/cache_test.go @@ -1,6 +1,8 @@ package cache import ( + "crypto/tls" + "slices" "testing" ) @@ -10,3 +12,26 @@ func TestRedisAddressSupportsIPv6(t *testing.T) { t.Fatalf("redisAddress() = %q", got) } } + +func TestRedisCLIConnectionArgsIncludeTLS(t *testing.T) { + previous := cacheConfig + cacheConfig = RedisConfig{Host: "redis.example.com", Port: "6380", User: "beast", DB: 2, TLS: true, CAFile: "/ca.pem", ServerName: "redis.example.com"} + defer func() { cacheConfig = previous }() + + arguments := redisCLIConnectionArgs() + for _, expected := range []string{"--tls", "--cacert", "/ca.pem", "--sni", "redis.example.com"} { + if !slices.Contains(arguments, expected) { + t.Fatalf("missing %q from Redis CLI arguments: %v", expected, arguments) + } + } +} + +func TestRedisTLSConfigRequiresTLS12(t *testing.T) { + config, err := NewTLSConfig(true, "", "redis.example.com", "redis.example.com") + if err != nil { + t.Fatal(err) + } + if config == nil || config.MinVersion != tls.VersionTLS12 || config.ServerName != "redis.example.com" { + t.Fatalf("unexpected TLS config: %+v", config) + } +} diff --git a/core/config/config.go b/core/config/config.go index 81a0d346..25bcc303 100644 --- a/core/config/config.go +++ b/core/config/config.go @@ -478,11 +478,14 @@ type PsqlConfig struct { } type RedisConfig struct { - User string `toml:"user"` - Password string `toml:"password"` - Host string `toml:"host"` - Port string `toml:"port"` - Db uint32 `toml:"db"` + User string `toml:"user"` + Password string `toml:"password"` + Host string `toml:"host"` + Port string `toml:"port"` + Db uint32 `toml:"db"` + TLS bool `toml:"tls"` + CAFile string `toml:"ca_file"` + ServerName string `toml:"server_name"` } func (config *PsqlConfig) ValidatePsqlConfig() error { @@ -517,6 +520,29 @@ func (config *RedisConfig) ValidateRedisConfig() error { if err := validateServiceAddress(config.Host, config.Port); err != nil { return fmt.Errorf("invalid redis address: %w", err) } + if !config.TLS { + ip := net.ParseIP(config.Host) + if config.Host != "localhost" && (ip == nil || !ip.IsLoopback()) { + return errors.New("TLS is required for non-loopback Redis connections") + } + if config.CAFile != "" || config.ServerName != "" { + return errors.New("Redis CA file and server name require TLS") + } + return nil + } + if config.ServerName != "" && net.ParseIP(config.ServerName) == nil && !hostnamePattern.MatchString(config.ServerName) { + return fmt.Errorf("invalid Redis TLS server name %q", config.ServerName) + } + if config.CAFile != "" { + caFile, err := utils.ExpandHomePath(config.CAFile) + if err != nil { + return fmt.Errorf("expand Redis CA file: %w", err) + } + config.CAFile = caFile + if err := utils.ValidateFileExists(config.CAFile); err != nil { + return fmt.Errorf("validate Redis CA file: %w", err) + } + } return nil } diff --git a/core/config/utils_test.go b/core/config/utils_test.go index 7c335b12..a46c4fb3 100644 --- a/core/config/utils_test.go +++ b/core/config/utils_test.go @@ -352,6 +352,17 @@ func TestDataStoreConfigRejectsUnsafeValues(t *testing.T) { } } +func TestRemoteRedisRequiresTLS(t *testing.T) { + redis := RedisConfig{User: "beast", Host: "redis.example.com", Port: "6379", Password: "secret"} + if err := redis.ValidateRedisConfig(); err == nil || !strings.Contains(err.Error(), "TLS is required") { + t.Fatalf("expected remote plaintext Redis rejection, got %v", err) + } + redis.TLS = true + if err := redis.ValidateRedisConfig(); err != nil { + t.Fatalf("expected remote TLS Redis config to pass: %v", err) + } +} + func TestCompetitionInfoValidatesTimeWindow(t *testing.T) { info := CompetitionInfo{ StartingTime: "00:00:00 UTC: +05:30, 1 January 2030, Tuesday", From 2a1ede28ceec2a708ea877c012395e342de55bac Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 14:22:40 +0530 Subject: [PATCH 104/149] fix(config): update challenge metadata atomically Signed-off-by: vibhatsu --- api/config.go | 249 +++++++++++++++++++----------- api/config_test.go | 51 ++++++ core/database/challenge_update.go | 52 +++++++ 3 files changed, 259 insertions(+), 93 deletions(-) create mode 100644 api/config_test.go create mode 100644 core/database/challenge_update.go diff --git a/api/config.go b/api/config.go index 3e1642a3..63004453 100644 --- a/api/config.go +++ b/api/config.go @@ -4,6 +4,9 @@ import ( "errors" "fmt" "net/http" + "net/url" + "path/filepath" + "regexp" "strconv" "strings" @@ -14,6 +17,8 @@ import ( "gorm.io/gorm" ) +var challengeTagPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,31}$`) + // This updates challenge info in the respective challenge configuration // @Summary Updates challenge info in the database, located at ~/.beast/beast.db. // @Description Updates challenge info in the database, located at ~/.beast/beast.db. @@ -21,128 +26,186 @@ import ( // @Accept json // @Produce json // @Param name formData string true "Challenge Name" -// @Param hints formData string true "Challenge's hints" // @Param desc formData string false "Challenge's description" -// @Param points formData string true "Challenge's points" -// @Param flag formData string true "Challenge's flag" -// @Param tags formData string true "Challenge's tags" -// @Param ports formData file false "Challenge's ports" +// @Param points formData string false "Challenge's points" +// @Param flag formData string false "Challenge's flag" +// @Param tags formData string false "Challenge's tags" +// @Param ports formData string false "Challenge's ports" // @Success 200 {object} api.HTTPPlainResp // @Failure 400 {object} api.HTTPPlainResp // @Failure 500 {object} api.HTTPErrorResp // @Router /api/config/challenge-info [post] func updateChallengeInfoHandler(c *gin.Context) { - name, exist := c.GetPostForm("name") - if !exist { - c.JSON(http.StatusBadRequest, HTTPPlainResp{ - Message: fmt.Sprintf("Can't edit challenge without challenge name"), - }) + name := strings.TrimSpace(c.PostForm("name")) + if name == "" { + c.JSON(http.StatusBadRequest, HTTPPlainResp{Message: "Can't edit challenge without challenge name"}) return } - configInfo := map[string]interface{}{ - "Name": name, - } - - desc, exist := c.GetPostForm("desc") - if exist { - configInfo["Description"] = desc - } - - points, exist := c.GetPostForm("points") - if exist { - configInfo["Points"] = points - } - - flag, exist := c.GetPostForm("flag") - if exist { - configInfo["Flag"] = flag - } - - assets, exist := c.GetPostForm("assets") - if exist { - configInfo["Assets"] = assets - } - - additionalLinks, exist := c.GetPostForm("additionalLinks") - if exist { - configInfo["AdditionalLinks"] = additionalLinks - } - - log.Debug(fmt.Sprintf("Starting to update the challenge : %s", name)) chall, err := database.QueryFirstChallengeEntry("name", name) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { c.JSON(http.StatusNotFound, HTTPErrorResp{Error: "Challenge not found"}) return } - log.Errorf("DB_ACCESS_ERROR : %s", err.Error()) - c.JSON(http.StatusInternalServerError, HTTPErrorResp{ - Error: fmt.Sprintf("DB_ACCESS_ERROR : %s", err.Error()), - }) + log.Errorf("database error while querying challenge %s: %v", name, err) + c.JSON(http.StatusInternalServerError, HTTPErrorResp{Error: "Database error while querying challenge"}) return } - // Update challenge - if e := database.UpdateChallenge(&chall, configInfo); e != nil { - c.JSON(http.StatusBadRequest, HTTPPlainResp{ - Message: fmt.Sprintf("Error while updating challenge info: %s", e.Error()), - }) + updates, ports, tags, err := parseChallengeUpdate(c, chall) + if err != nil { + c.JSON(http.StatusBadRequest, HTTPPlainResp{Message: err.Error()}) + return + } + if err := database.UpdateChallengeConfiguration(chall.ID, updates, ports, tags); err != nil { + log.Errorf("failed to update challenge %s: %v", name, err) + c.JSON(http.StatusInternalServerError, HTTPPlainResp{Message: "Failed to update challenge"}) return } - // Update ports - ports, exist := c.GetPostForm("ports") - if exist { - if err := database.UpdatePorts(&chall); err != nil { - c.JSON(http.StatusBadRequest, HTTPPlainResp{ - Message: fmt.Sprintf("Error: %s", err.Error()), - }) - return - } - ports = strings.ReplaceAll(ports, " ", "") - portsArray := strings.Split(ports, ",") - for _, port := range portsArray { - u64, err := strconv.ParseUint(port, 10, 32) - if err != nil { - c.JSON(http.StatusBadRequest, HTTPPlainResp{ - Message: fmt.Sprintf("Error: %s", err.Error()), - }) - return - } + c.JSON(http.StatusOK, HTTPPlainResp{Message: fmt.Sprintf("Successfully updated challenge: %s", name)}) +} - newPort := database.Port{ChallengeID: chall.ID, PortNo: uint32(u64)} - _, err = database.PortEntryGetOrCreate(&newPort) - if err != nil { - c.JSON(http.StatusBadRequest, HTTPPlainResp{ - Message: fmt.Sprintf("Error while updating challenge ports: %s", err.Error()), - }) - return - } +func parseChallengeUpdate(c *gin.Context, challenge database.Challenge) (map[string]interface{}, *[]uint32, *[]string, error) { + updates := make(map[string]interface{}) + if description, exists := c.GetPostForm("desc"); exists { + if len(description) > 64<<10 { + return nil, nil, nil, errors.New("description exceeds 64 KiB") + } + updates["description"] = description + } + if pointsValue, exists := c.GetPostForm("points"); exists { + points, err := strconv.ParseUint(strings.TrimSpace(pointsValue), 10, 32) + if err != nil { + return nil, nil, nil, errors.New("points must be an unsigned 32-bit integer") + } + if challenge.MaxPoints > 0 && uint(points) > challenge.MaxPoints || uint(points) < challenge.MinPoints { + return nil, nil, nil, errors.New("points are outside the configured scoring range") + } + updates["points"] = uint(points) + } + if flag, exists := c.GetPostForm("flag"); exists { + if (!challenge.DynamicFlag && flag == "") || len(flag) > 4096 { + return nil, nil, nil, errors.New("flag must contain between 1 and 4096 bytes") + } + updates["flag"] = flag + } + if assets, exists := c.GetPostForm("assets"); exists { + if err := validateAssetList(assets); err != nil { + return nil, nil, nil, err + } + updates["assets"] = assets + } + if links, exists := c.GetPostForm("additionalLinks"); exists { + if err := validateAdditionalLinks(links); err != nil { + return nil, nil, nil, err } + updates["additional_links"] = links } - // Update Tags - tags, exist := c.GetPostForm("tags") - if exist { - tagsArray := strings.Split(tags, core.DELIMITER) - tagArr := make([]*database.Tag, len(tagsArray)) + ports, err := parsePorts(c) + if err != nil { + return nil, nil, nil, err + } + tags, err := parseTags(c) + if err != nil { + return nil, nil, nil, err + } + return updates, ports, tags, nil +} + +func parsePorts(c *gin.Context) (*[]uint32, error) { + value, exists := c.GetPostForm("ports") + if !exists { + return nil, nil + } + ports := make([]uint32, 0) + seen := make(map[uint32]struct{}) + for _, rawPort := range strings.Split(value, ",") { + if strings.TrimSpace(rawPort) == "" { + continue + } + port, err := strconv.ParseUint(strings.TrimSpace(rawPort), 10, 16) + if err != nil || port == 0 { + return nil, errors.New("ports must be comma-separated integers from 1 to 65535") + } + portNumber := uint32(port) + if _, duplicate := seen[portNumber]; duplicate { + return nil, fmt.Errorf("duplicate port %d", portNumber) + } + seen[portNumber] = struct{}{} + ports = append(ports, portNumber) + if len(ports) > 256 { + return nil, errors.New("at most 256 ports are allowed") + } + } + return &ports, nil +} - for index, tag := range tagsArray { - tagArr[index] = &database.Tag{ - TagName: tag, - } +func parseTags(c *gin.Context) (*[]string, error) { + value, exists := c.GetPostForm("tags") + if !exists { + return nil, nil + } + tags := make([]string, 0) + seen := make(map[string]struct{}) + for _, rawTag := range strings.Split(value, core.DELIMITER) { + tag := strings.TrimSpace(rawTag) + if tag == "" { + continue } + if !challengeTagPattern.MatchString(tag) { + return nil, fmt.Errorf("invalid tag %q", tag) + } + if _, duplicate := seen[tag]; duplicate { + continue + } + seen[tag] = struct{}{} + tags = append(tags, tag) + if len(tags) > 32 { + return nil, errors.New("at most 32 tags are allowed") + } + } + return &tags, nil +} - if err = database.UpdateTags(tagArr, &chall); err != nil { - c.JSON(http.StatusBadRequest, HTTPPlainResp{ - Message: fmt.Sprintf("Error while updating tags: %s", err.Error()), - }) +func validateAssetList(value string) error { + assets := strings.Split(value, core.DELIMITER) + if len(assets) > 128 { + return errors.New("at most 128 assets are allowed") + } + for _, asset := range assets { + if asset == "" { + continue + } + if len(asset) > 255 { + return errors.New("asset path exceeds 255 bytes") + } + cleaned := filepath.Clean(asset) + if filepath.IsAbs(asset) || cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) || strings.Contains(asset, `\`) { + return fmt.Errorf("invalid asset path %q", asset) } } + return nil +} - c.JSON(http.StatusOK, HTTPPlainResp{ - Message: fmt.Sprintf("Succesfully updated challenge: %s", name), - }) - return +func validateAdditionalLinks(value string) error { + links := strings.Split(value, core.DELIMITER) + if len(links) > 32 { + return errors.New("at most 32 additional links are allowed") + } + for _, link := range links { + if link == "" { + continue + } + if len(link) > 2048 { + return errors.New("additional link exceeds 2048 bytes") + } + parsed, err := url.ParseRequestURI(link) + if err != nil || (parsed.Scheme != "https" && parsed.Scheme != "http") || parsed.Host == "" || parsed.User != nil { + return fmt.Errorf("invalid additional link %q", link) + } + } + return nil } diff --git a/api/config_test.go b/api/config_test.go new file mode 100644 index 00000000..3ff22cac --- /dev/null +++ b/api/config_test.go @@ -0,0 +1,51 @@ +package api + +import ( + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/sdslabs/beastv4/core" + "github.com/sdslabs/beastv4/core/database" +) + +func challengeUpdateContext(values url.Values) *gin.Context { + request := httptest.NewRequest("POST", "/", strings.NewReader(values.Encode())) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + context, _ := gin.CreateTestContext(httptest.NewRecorder()) + context.Request = request + return context +} + +func TestParseChallengeUpdateRejectsInvalidValues(t *testing.T) { + challenge := database.Challenge{MaxPoints: 500, MinPoints: 100} + tests := []url.Values{ + {"points": {"99"}}, + {"ports": {"80,80"}}, + {"tags": {"web" + core.DELIMITER + "../admin"}}, + {"assets": {"../flag"}}, + {"additionalLinks": {"file:///etc/passwd"}}, + } + for _, values := range tests { + if _, _, _, err := parseChallengeUpdate(challengeUpdateContext(values), challenge); err == nil { + t.Fatalf("expected invalid update to fail: %v", values) + } + } +} + +func TestParseChallengeUpdateProducesTypedValues(t *testing.T) { + values := url.Values{ + "points": {"250"}, + "ports": {"8080, 8443"}, + "tags": {"web" + core.DELIMITER + "beginner"}, + } + updates, ports, tags, err := parseChallengeUpdate(challengeUpdateContext(values), database.Challenge{MaxPoints: 500, MinPoints: 100}) + if err != nil { + t.Fatal(err) + } + if updates["points"] != uint(250) || len(*ports) != 2 || len(*tags) != 2 { + t.Fatalf("unexpected parsed update: updates=%v ports=%v tags=%v", updates, ports, tags) + } +} diff --git a/core/database/challenge_update.go b/core/database/challenge_update.go new file mode 100644 index 00000000..43653ce8 --- /dev/null +++ b/core/database/challenge_update.go @@ -0,0 +1,52 @@ +package database + +import ( + "fmt" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +func UpdateChallengeConfiguration(challengeID uint, updates map[string]interface{}, ports *[]uint32, tags *[]string) error { + if challengeID == 0 { + return fmt.Errorf("persisted challenge is required") + } + + DBMux.Lock() + defer DBMux.Unlock() + return Db.Transaction(func(tx *gorm.DB) error { + var challenge Challenge + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&challenge, challengeID).Error; err != nil { + return err + } + if len(updates) > 0 { + if err := tx.Model(&challenge).Omit(clause.Associations).Updates(updates).Error; err != nil { + return err + } + } + if ports != nil { + if err := tx.Unscoped().Where("challenge_id = ?", challengeID).Delete(&Port{}).Error; err != nil { + return err + } + for _, portNumber := range *ports { + if err := tx.Create(&Port{ChallengeID: challengeID, PortNo: portNumber}).Error; err != nil { + return fmt.Errorf("reserve port %d: %w", portNumber, err) + } + } + } + if tags != nil { + tagModels := make([]*Tag, 0, len(*tags)) + for _, tagName := range *tags { + tag := &Tag{TagName: tagName} + if err := tx.FirstOrCreate(tag, Tag{TagName: tagName}).Error; err != nil { + return err + } + tagModels = append(tagModels, tag) + } + if err := tx.Model(&challenge).Association("Tags").Replace(tagModels); err != nil { + return err + } + } + return nil + }) +} From f881fd29b1b955959aba6791922a5a2221bba312 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 14:23:47 +0530 Subject: [PATCH 105/149] fix(database): restore notification constraints Signed-off-by: vibhatsu --- core/database/notification.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/database/notification.go b/core/database/notification.go index 4c360bd7..f6495f1f 100644 --- a/core/database/notification.go +++ b/core/database/notification.go @@ -11,8 +11,8 @@ import ( type Notification struct { gorm.Model - Title string `gorm:not null;unique` - Description string `gorm:not null` + Title string `gorm:"not null;unique"` + Description string `gorm:"not null"` } // Create an entry for the notification in the Notification table From 4ee8418a7e933479daaadbbd55365d30cedffa28 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 14:29:34 +0530 Subject: [PATCH 106/149] fix(cli): keep passwords out of arguments Signed-off-by: vibhatsu --- client/authorize.go | 87 +++++++++++--------- client/authorize_test.go | 9 +++ cmd/beast/authorize.go | 30 +++---- cmd/beast/commands.go | 48 ++++++----- cmd/beast/create_admin_author.go | 131 ++++++++++++++++--------------- cmd/beast/run.go | 37 ++++++++- cmd/beast/run_test.go | 22 ++++++ 7 files changed, 223 insertions(+), 141 deletions(-) create mode 100644 client/authorize_test.go diff --git a/client/authorize.go b/client/authorize.go index ee080f1b..6a3b426c 100644 --- a/client/authorize.go +++ b/client/authorize.go @@ -1,65 +1,78 @@ package client import ( + "crypto/tls" + "crypto/x509" "encoding/json" "fmt" - "io/ioutil" + "io" "net/http" "net/url" + "os" + "time" ) +const maxAuthorizeResponseBytes = 1 << 20 + type Response struct { Message string `json:"message"` Challenge []byte `json:"challenge"` Token string `json:"token"` } -func Authorize(password string, host string, username string) { - - u, err := url.Parse("auth/login") - if err != nil { - fmt.Printf("Error while parsing url : %v", err) - return - } - +func Authorize(password, host, username, caFile string) (Response, error) { base, err := url.Parse(host) - if err != nil { - fmt.Printf("Error while parsing url : %v", err) - return + if err != nil || base.Scheme != "https" || base.Host == "" || base.User != nil { + return Response{}, fmt.Errorf("Beast host must be a valid HTTPS URL") } - - res, err := http.PostForm(base.ResolveReference(u).String(), url.Values{ + tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12} + if caFile != "" { + certificate, err := os.ReadFile(caFile) + if err != nil { + return Response{}, fmt.Errorf("read CA file: %w", err) + } + roots, err := x509.SystemCertPool() + if err != nil || roots == nil { + roots = x509.NewCertPool() + } + if !roots.AppendCertsFromPEM(certificate) { + return Response{}, fmt.Errorf("CA file contains no certificates") + } + tlsConfig.RootCAs = roots + } + httpClient := &http.Client{ + Timeout: 15 * time.Second, + Transport: &http.Transport{TLSClientConfig: tlsConfig}, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + }, + } + endpoint := base.ResolveReference(&url.URL{Path: "auth/login"}) + response, err := httpClient.PostForm(endpoint.String(), url.Values{ "username": {username}, "password": {password}, }) if err != nil { - fmt.Printf("Error while making post request : %v", err) - return + return Response{}, fmt.Errorf("authorize request: %w", err) } - - body, err := ioutil.ReadAll(res.Body) + defer response.Body.Close() + body, err := io.ReadAll(io.LimitReader(response.Body, maxAuthorizeResponseBytes+1)) if err != nil { - fmt.Printf("Error while making post request : %v", err) - return + return Response{}, fmt.Errorf("read authorize response: %w", err) } - - if res.StatusCode != http.StatusOK { - fmt.Printf("Response code : %v \nBody : %v", res.StatusCode, string(body)) - return + if len(body) > maxAuthorizeResponseBytes { + return Response{}, fmt.Errorf("authorize response exceeds 1 MiB") } - - var response Response - - err = json.Unmarshal(body, &response) - if err != nil { - fmt.Printf("Error while parsing response : %v", err) - return + if response.StatusCode != http.StatusOK { + return Response{}, fmt.Errorf("authorization failed with HTTP status %d", response.StatusCode) } - fmt.Printf(` -The response: -Token : %v -Message : %v - `, response.Token, response.Message) - + var result Response + if err := json.Unmarshal(body, &result); err != nil { + return Response{}, fmt.Errorf("parse authorize response: %w", err) + } + if result.Token == "" { + return Response{}, fmt.Errorf("authorize response did not contain a token") + } + return result, nil } diff --git a/client/authorize_test.go b/client/authorize_test.go new file mode 100644 index 00000000..1591be85 --- /dev/null +++ b/client/authorize_test.go @@ -0,0 +1,9 @@ +package client + +import "testing" + +func TestAuthorizeRequiresHTTPS(t *testing.T) { + if _, err := Authorize("password", "http://localhost:5005", "user", ""); err == nil { + t.Fatal("expected plaintext authorization URL to fail") + } +} diff --git a/cmd/beast/authorize.go b/cmd/beast/authorize.go index 5a2962d6..eaa57535 100644 --- a/cmd/beast/authorize.go +++ b/cmd/beast/authorize.go @@ -2,28 +2,30 @@ package main import ( "fmt" - "os" + "strings" "github.com/sdslabs/beastv4/client" + "github.com/sdslabs/beastv4/utils" "github.com/spf13/cobra" ) var getAuthCmd = &cobra.Command{ Use: "getauth", - Short: "Gets Auth token from beast server", - Long: "Gets Auth Token from the beast server by completing the challenge from the server", - PreRun: func(cmd *cobra.Command, args []string) { - if Password == "" { - fmt.Printf("Password not provided") - os.Exit(1) + Short: "Gets an authentication token from the Beast server", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if strings.TrimSpace(Username) == "" { + return fmt.Errorf("username is required") } - - if Username == "" { - fmt.Printf("Username not provided") - os.Exit(1) + password := utils.PromptSecret("Enter Beast password") + if password == "" { + return fmt.Errorf("password is required") } - }, - Run: func(cmd *cobra.Command, args []string) { - client.Authorize(Password, Host, Username) + response, err := client.Authorize(password, Host, Username, AuthCAFile) + if err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "Token\t: %s\nMessage\t: %s\n", response.Token, response.Message) + return nil }, } diff --git a/cmd/beast/commands.go b/cmd/beast/commands.go index d0f4b680..d2a00155 100644 --- a/cmd/beast/commands.go +++ b/cmd/beast/commands.go @@ -10,26 +10,26 @@ import ( ) var ( - Verbose bool - HealthProbe bool - Port string - DefaultAuthorPassword string - Name string - Host string - Username string - Email string - Password string - AllChalls bool - AutoDeploy bool - PeriodicSync bool - Tag string - LocalDirectory string - DeleteEntry bool - RefDirectory string - Status string - Tags string - NoCache bool - RestoreFile string + Verbose bool + HealthProbe bool + Port string + DefaultAuthorPasswordFile string + Name string + Host string + Username string + Email string + AuthCAFile string + AllChalls bool + AutoDeploy bool + PeriodicSync bool + Tag string + LocalDirectory string + DeleteEntry bool + RefDirectory string + Status string + Tags string + NoCache bool + RestoreFile string ) // Root command `beast` all commands are either a flag to this command @@ -69,24 +69,22 @@ func init() { rootCmd.PersistentFlags().BoolVarP(&Verbose, "verbose", "v", false, "Print extra information in stdout1") runCmd.PersistentFlags().StringVarP(&Port, "port", "p", "", "Port to run the beast server on.") - runCmd.PersistentFlags().StringVarP(&DefaultAuthorPassword, "defaultauthorpassword", "q", "", "Default password for creating author, users are not created if value is empty string") + runCmd.PersistentFlags().StringVar(&DefaultAuthorPasswordFile, "default-author-password-file", "", "0600 file containing the password used to create missing authors") runCmd.PersistentFlags().BoolVarP(&AutoDeploy, "auto-deploy", "a", false, "Auto deploy all challenges from remote on server start.") runCmd.PersistentFlags().BoolVarP(&HealthProbe, "health-probe", "k", false, "Run health check service for beast deployed challenges") runCmd.PersistentFlags().BoolVarP(&PeriodicSync, "periodic-sync", "s", false, "Periodically sync remote with beast and auto update challenges.") runCmd.PersistentFlags().BoolVarP(&NoCache, "no-cache", "c", false, "Build image of challenge without using cache") getAuthCmd.PersistentFlags().StringVarP(&Username, "username", "u", "", "Username") - getAuthCmd.PersistentFlags().StringVarP(&Password, "password", "p", "", "Password") - getAuthCmd.PersistentFlags().StringVarP(&Host, "host", "H", "http://localhost:5005/", "Hostname or IP along with port where beast is hosted") + getAuthCmd.PersistentFlags().StringVarP(&Host, "host", "H", "https://localhost:5005/", "HTTPS URL where Beast is hosted") + getAuthCmd.PersistentFlags().StringVar(&AuthCAFile, "ca-file", "", "CA certificate used to verify the Beast server") createAuthorCmd.PersistentFlags().StringVarP(&Name, "name", "", "", "Name of the new author") createAuthorCmd.PersistentFlags().StringVarP(&Username, "username", "", "", "Username of the new author") - createAuthorCmd.PersistentFlags().StringVarP(&Password, "password", "", "", "Password of the author") createAuthorCmd.PersistentFlags().StringVarP(&Email, "email", "", "", "Email of the new author") createAdminCmd.PersistentFlags().StringVarP(&Name, "name", "", "", "Name of the new admin") createAdminCmd.PersistentFlags().StringVarP(&Username, "username", "", "", "Username of the new admin") - createAdminCmd.PersistentFlags().StringVarP(&Password, "password", "", "", "Password of the admin") createAdminCmd.PersistentFlags().StringVarP(&Email, "email", "", "", "Email of the new admin") challengeCmd.PersistentFlags().BoolVarP(&AllChalls, "all", "a", false, "Performs action to all challs") diff --git a/cmd/beast/create_admin_author.go b/cmd/beast/create_admin_author.go index d50c46d5..e30ed97c 100644 --- a/cmd/beast/create_admin_author.go +++ b/cmd/beast/create_admin_author.go @@ -2,92 +2,95 @@ package main import ( "fmt" - "os" + "net/mail" + "regexp" + "strings" "github.com/sdslabs/beastv4/core" "github.com/sdslabs/beastv4/core/config" - "github.com/sdslabs/beastv4/core/utils" + "github.com/sdslabs/beastv4/core/database" + coreUtils "github.com/sdslabs/beastv4/core/utils" "github.com/sdslabs/beastv4/pkg/auth" + "github.com/sdslabs/beastv4/utils" "github.com/spf13/cobra" ) +var managerUsernamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_.-]{2,31}$`) + func createAuthorAdminPrereq() error { if err := config.InitConfig(); err != nil { return err } auth.Init(core.ITERATIONS, core.HASH_LENGTH, core.TIMEPERIOD, core.ISSUER, config.Cfg.JWTSecret, []string{core.USER_ROLES["author"], core.USER_ROLES["maintainer"]}, []string{core.USER_ROLES["admin"]}, []string{core.USER_ROLES["contestant"]}) - return nil + return database.Init() } -var createAuthorCmd = &cobra.Command{ - Use: "create-author", - Short: "Creates new author", - Long: "Creates new author using command line arguments", - PreRun: func(cmd *cobra.Command, args []string) { - if Name == "" { - fmt.Printf("Name of Author not provided") - os.Exit(1) - } - if Username == "" { - fmt.Printf("Username of Author not provided") - os.Exit(1) - } +func validateManagerArguments(role string) error { + if strings.TrimSpace(Name) == "" { + return fmt.Errorf("name of %s is required", role) + } + if len(Name) > 128 { + return fmt.Errorf("name must not exceed 128 bytes") + } + if !managerUsernamePattern.MatchString(Username) { + return fmt.Errorf("username must match %s", managerUsernamePattern.String()) + } + mailbox, err := mail.ParseAddress(Email) + if err != nil || mailbox.Address != Email { + return fmt.Errorf("email must be a canonical mailbox address") + } + return nil +} - if Email == "" { - fmt.Printf("Email not provided") - os.Exit(1) - } +func promptNewPassword() (string, error) { + password := utils.PromptSecret("Enter password") + confirmation := utils.PromptSecret("Confirm password") + if password != confirmation { + return "", fmt.Errorf("password confirmation does not match") + } + if len(password) < 12 || len(password) > 128 || strings.TrimSpace(password) == "" { + return "", fmt.Errorf("password must contain between 12 and 128 non-whitespace bytes") + } + return password, nil +} - if Password == "" { - fmt.Printf("Password not provided") - os.Exit(1) - } - }, +func createManager(role string) error { + if err := validateManagerArguments(role); err != nil { + return err + } + password, err := promptNewPassword() + if err != nil { + return err + } + if err := createAuthorAdminPrereq(); err != nil { + return fmt.Errorf("initialize configuration: %w", err) + } + sqlDB, err := database.Db.DB() + if err != nil { + return fmt.Errorf("access database connection: %w", err) + } + defer sqlDB.Close() + if err := coreUtils.CreateAdminOrAuthor(Name, Username, Email, password, role); err != nil { + return fmt.Errorf("create %s: %w", role, err) + } + return nil +} - Run: func(cmd *cobra.Command, args []string) { - if err := createAuthorAdminPrereq(); err != nil { - fmt.Printf("Unable to initialize configuration: %v\n", err) - return - } - if err := utils.CreateAdminOrAuthor(Name, Username, Email, Password, "author"); err != nil { - fmt.Printf("Unable to create author: %v\n", err) - } +var createAuthorCmd = &cobra.Command{ + Use: "create-author", + Short: "Creates a new author", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return createManager("author") }, } var createAdminCmd = &cobra.Command{ Use: "create-admin", - Short: "Creates new admin", - Long: "Creates new admin using command line arguments", - PreRun: func(cmd *cobra.Command, args []string) { - if Name == "" { - fmt.Printf("Name of Admin not provided") - os.Exit(1) - } - if Username == "" { - fmt.Printf("Username of Admin not provided") - os.Exit(1) - } - - if Email == "" { - fmt.Printf("Email not provided") - os.Exit(1) - } - - if Password == "" { - fmt.Printf("Password not provided") - os.Exit(1) - } - }, - - Run: func(cmd *cobra.Command, args []string) { - if err := createAuthorAdminPrereq(); err != nil { - fmt.Printf("Unable to initialize configuration: %v\n", err) - return - } - if err := utils.CreateAdminOrAuthor(Name, Username, Email, Password, "admin"); err != nil { - fmt.Printf("Unable to create admin: %v\n", err) - } + Short: "Creates a new admin", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return createManager("admin") }, } diff --git a/cmd/beast/run.go b/cmd/beast/run.go index 2ac206c0..782fd38f 100644 --- a/cmd/beast/run.go +++ b/cmd/beast/run.go @@ -5,11 +5,13 @@ import ( "encoding/json" "fmt" "github.com/sdslabs/beastv4/core/cache" + "io" "math" "os" "os/signal" "path/filepath" "strconv" + "strings" "syscall" "github.com/sdslabs/beastv4/core" @@ -17,6 +19,7 @@ import ( "github.com/sdslabs/beastv4/core/manager" "github.com/sdslabs/beastv4/pkg/remoteManager" "github.com/sdslabs/beastv4/pkg/sse" + "github.com/sdslabs/beastv4/utils" "github.com/sdslabs/beastv4/api" log "github.com/sirupsen/logrus" @@ -25,6 +28,33 @@ import ( const controllerLockFile = "controller.lock" +func loadDefaultAuthorPassword(path string) (string, error) { + if path == "" { + return "", nil + } + expanded, err := utils.ExpandHomePath(path) + if err != nil { + return "", err + } + if err := utils.ValidateSecretFile(expanded); err != nil { + return "", fmt.Errorf("validate default author password file: %w", err) + } + file, err := os.Open(expanded) + if err != nil { + return "", fmt.Errorf("open default author password file: %w", err) + } + defer file.Close() + contents, err := io.ReadAll(io.LimitReader(file, 130)) + if err != nil { + return "", fmt.Errorf("read default author password file: %w", err) + } + password := strings.TrimSuffix(strings.TrimSuffix(string(contents), "\n"), "\r") + if len(password) < 12 || len(password) > 128 || strings.TrimSpace(password) == "" { + return "", fmt.Errorf("default author password must contain between 12 and 128 non-whitespace bytes") + } + return password, nil +} + func acquireControllerLock(directory string) (*os.File, error) { path := filepath.Join(directory, controllerLockFile) file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0600) @@ -246,7 +276,12 @@ var runCmd = &cobra.Command{ ctx, stopSignals := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stopSignals() - err = api.RunBeastApiServer(ctx, Port, DefaultAuthorPassword, AutoDeploy, HealthProbe, PeriodicSync, NoCache) + defaultAuthorPassword, err := loadDefaultAuthorPassword(DefaultAuthorPasswordFile) + if err != nil { + log.Error(err) + return + } + err = api.RunBeastApiServer(ctx, Port, defaultAuthorPassword, AutoDeploy, HealthProbe, PeriodicSync, NoCache) if err != nil { log.Errorf("Beast API stopped: %v", err) } diff --git a/cmd/beast/run_test.go b/cmd/beast/run_test.go index 888086e7..0d600c9e 100644 --- a/cmd/beast/run_test.go +++ b/cmd/beast/run_test.go @@ -1,6 +1,8 @@ package main import ( + "os" + "path/filepath" "strings" "testing" ) @@ -23,6 +25,26 @@ func TestAcquireControllerLockRejectsSecondController(t *testing.T) { } } +func TestLoadDefaultAuthorPasswordRequiresPrivateFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "password") + if err := os.WriteFile(path, []byte("a-secure-password\n"), 0644); err != nil { + t.Fatal(err) + } + if _, err := loadDefaultAuthorPassword(path); err == nil { + t.Fatal("expected public password file to fail") + } + if err := os.Chmod(path, 0600); err != nil { + t.Fatal(err) + } + password, err := loadDefaultAuthorPassword(path) + if err != nil { + t.Fatal(err) + } + if password != "a-secure-password" { + t.Fatalf("unexpected password %q", password) + } +} + func TestControllerLockCanBeReacquiredAfterRelease(t *testing.T) { directory := t.TempDir() first, err := acquireControllerLock(directory) From e5734fe1a29a1d369908a506890243f99060fd8e Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 14:33:05 +0530 Subject: [PATCH 107/149] fix(lifecycle): propagate server failures Signed-off-by: vibhatsu --- api/main.go | 15 +++++++++++---- cmd/beast/run.go | 40 ++++++++++++++++++++++++---------------- core/cache/cache.go | 5 ++--- 3 files changed, 37 insertions(+), 23 deletions(-) diff --git a/api/main.go b/api/main.go index 747e8d63..3dac9d22 100644 --- a/api/main.go +++ b/api/main.go @@ -95,18 +95,25 @@ func RunBeastApiServer(ctx context.Context, port, defaultauthorpassword string, return err } - manager.Q = wpool.InitQueue(core.MAX_QUEUE_SIZE, nil) - manager.Q.StartWorkers(&manager.Worker{}) - auth.Init(core.ITERATIONS, core.HASH_LENGTH, core.TIMEPERIOD, core.ISSUER, config.Cfg.JWTSecret, []string{core.USER_ROLES["author"], core.USER_ROLES["maintainer"]}, []string{core.USER_ROLES["admin"]}, []string{core.USER_ROLES["contestant"]}) - remoteManager.Init() if err := database.Init(); err != nil { + if database.Db != nil { + if sqlDB, dbErr := database.Db.DB(); dbErr == nil { + _ = sqlDB.Close() + } + } return err } cache.Configure(config.Cfg.RedisConf.User, config.Cfg.RedisConf.Password, config.Cfg.RedisConf.Host, config.Cfg.RedisConf.Port, config.Cfg.RedisConf.Db, config.Cfg.RedisConf.TLS, config.Cfg.RedisConf.CAFile, config.Cfg.RedisConf.ServerName) if err := cache.Init(); err != nil { + if sqlDB, dbErr := database.Db.DB(); dbErr == nil { + _ = sqlDB.Close() + } return err } + manager.Q = wpool.InitQueue(core.MAX_QUEUE_SIZE, nil) + manager.Q.StartWorkers(&manager.Worker{}) + remoteManager.Init() backgroundCtx, stopBackground := context.WithCancel(ctx) dynamicScoreDone := startDynamicScoreWorker(backgroundCtx) instanceCleanupDone := make(chan struct{}) diff --git a/cmd/beast/run.go b/cmd/beast/run.go index 782fd38f..171ff6c3 100644 --- a/cmd/beast/run.go +++ b/cmd/beast/run.go @@ -129,7 +129,7 @@ func cleanupRunningContainers() { _ = manager.KillChallengeInstances(challenge.Name) } - err = manager.UndeployChallenge(challenge.Name) + err = manager.StartUndeployChallenge(challenge.Name, false) if err != nil { log.Errorln(fmt.Sprintf("Failed to undeploy challenge [Id: %v] %s", challenge.ID, challenge.Name)) log.Errorln(err.Error()) @@ -172,9 +172,15 @@ func cleanupDatabaseConnections() { log.Infoln("Terminating database connection...") - err = database.TerminateDatabaseConnections() + if database.Db == nil { + log.Infoln("Database was not initialized") + return + } + sqlDB, err := database.Db.DB() if err != nil { - log.Errorln("Unable to terminate database connections:", err) + log.Errorln("Unable to access database connection:", err) + } else if err := sqlDB.Close(); err != nil { + log.Errorln("Unable to close database connection:", err) } else { log.Infoln("Database connections terminated successfully") } @@ -248,28 +254,28 @@ var runCmd = &cobra.Command{ Short: "Run Beast API server", Long: "Run beast API server using beast/api/server, optionally an argument can be provided to specify the port to run the server on.", - Run: func(cmd *cobra.Command, args []string) { + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { if _, err := os.Stat(core.BEAST_GLOBAL_DIR); os.IsNotExist(err) { log.Infof("%s directory not found... running Beast bootsteps...\n", core.BEAST_GLOBAL_DIR) if err := runBeastBootsteps(); err != nil { - log.Error("Error while running Beast bootsteps.") - os.Exit(1) + return fmt.Errorf("run Beast bootsteps: %w", err) } log.Infoln("beast bootsteps complete... starting beast server") + } else if err != nil { + return fmt.Errorf("inspect Beast directory: %w", err) } if Port != "" { port, err := strconv.Atoi(Port) if err != nil || port < 1 || port > 65535 { - log.Errorf("invalid API port %q", Port) - return + return fmt.Errorf("invalid API port %q", Port) } } controllerLock, err := acquireControllerLock(core.BEAST_GLOBAL_DIR) if err != nil { - log.Error(err) - return + return err } defer releaseControllerLock(controllerLock) @@ -278,17 +284,19 @@ var runCmd = &cobra.Command{ defaultAuthorPassword, err := loadDefaultAuthorPassword(DefaultAuthorPasswordFile) if err != nil { - log.Error(err) - return + return err } err = api.RunBeastApiServer(ctx, Port, defaultAuthorPassword, AutoDeploy, HealthProbe, PeriodicSync, NoCache) - if err != nil { - log.Errorf("Beast API stopped: %v", err) - } if ctx.Err() != nil { log.Infoln("Shutdown signal received.") } - cleanup() + if manager.Q != nil && database.Db != nil { + cleanup() + } + if err != nil { + return fmt.Errorf("Beast API stopped: %w", err) + } log.Infoln("Server stopped gracefully.") + return nil }, } diff --git a/core/cache/cache.go b/core/cache/cache.go index 564b1338..dac7dae9 100644 --- a/core/cache/cache.go +++ b/core/cache/cache.go @@ -197,10 +197,9 @@ func Close() error { err := Cache.Close() if err != nil { - log.Errorln(fmt.Sprintf("Error while closing cache connection gracefully: %s, attempting to terminate forcefully", err.Error())) - return TerminateCacheConnections() + return fmt.Errorf("close cache connection: %w", err) } - + Cache = nil return nil } From d996fe185712bb8ce08e06ae73ac5e9fe5228dbb Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 14:41:50 +0530 Subject: [PATCH 108/149] fix(cli): guard destructive maintenance Signed-off-by: vibhatsu --- cmd/beast/backup.go | 24 ++++++--- cmd/beast/cache.go | 41 ++++++++++----- cmd/beast/commands.go | 5 ++ cmd/beast/database.go | 41 ++++++++++----- cmd/beast/maintenance.go | 31 +++++++++++ cmd/beast/maintenance_test.go | 17 ++++++ core/cache/cache.go | 97 +++-------------------------------- core/database/database.go | 92 +++------------------------------ 8 files changed, 143 insertions(+), 205 deletions(-) create mode 100644 cmd/beast/maintenance.go create mode 100644 cmd/beast/maintenance_test.go diff --git a/cmd/beast/backup.go b/cmd/beast/backup.go index 59b1e2f7..060972ea 100644 --- a/cmd/beast/backup.go +++ b/cmd/beast/backup.go @@ -8,16 +8,28 @@ import ( var backupDatabase = &cobra.Command{ Use: "backup-database", - Short: "Backups the existing database and remote/staging directories", - Run: func(cmd *cobra.Command, args []string) { - database.BackupDatabase() + Short: "Backs up the configured PostgreSQL database", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + cleanup, err := initializeMaintenance(false) + if err != nil { + return err + } + defer cleanup() + return database.BackupDatabase() }, } var backupCache = &cobra.Command{ Use: "backup-cache", - Short: "Backups the existing cache and remote/staging directories", - Run: func(cmd *cobra.Command, args []string) { - cache.BackupCache() + Short: "Backs up the configured Redis database", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + cleanup, err := initializeMaintenance(true) + if err != nil { + return err + } + defer cleanup() + return cache.BackupCache() }, } diff --git a/cmd/beast/cache.go b/cmd/beast/cache.go index c3b9086c..e667597b 100644 --- a/cmd/beast/cache.go +++ b/cmd/beast/cache.go @@ -1,30 +1,45 @@ package main import ( + "fmt" + "github.com/sdslabs/beastv4/core/cache" - log "github.com/sirupsen/logrus" "github.com/spf13/cobra" ) var resetCacheCmd = &cobra.Command{ Use: "reset-cache", - Short: "Backups the existing cache and cleans up old cache and remote/staging directories", - Run: func(cmd *cobra.Command, args []string) { - cache.BackupAndReset() + Short: "Backs up and resets the configured Redis database", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if err := requireDestructiveConfirmation(); err != nil { + return err + } + cleanup, err := initializeMaintenance(true) + if err != nil { + return err + } + defer cleanup() + return cache.BackupAndReset() }, } var restoreCacheCmd = &cobra.Command{ Use: "restore-cache", - Short: "Restores the cache, with the backed-up file", - Run: func(cmd *cobra.Command, args []string) { - if RestoreFile != "" { - err := cache.RestoreCache(RestoreFile) - if err != nil { - log.Errorf("Error restoring cache from file %s: %v\n", RestoreFile, err) - } - } else { - log.Fatalf("Restore file not specified.") + Short: "Restores the configured Redis database from a backup", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if err := requireDestructiveConfirmation(); err != nil { + return err + } + if RestoreFile == "" { + return fmt.Errorf("restore file is required") + } + cleanup, err := initializeMaintenance(true) + if err != nil { + return err } + defer cleanup() + return cache.RestoreCache(RestoreFile) }, } diff --git a/cmd/beast/commands.go b/cmd/beast/commands.go index d2a00155..0f7a5dcf 100644 --- a/cmd/beast/commands.go +++ b/cmd/beast/commands.go @@ -30,6 +30,7 @@ var ( Tags string NoCache bool RestoreFile string + ConfirmDestructive bool ) // Root command `beast` all commands are either a flag to this command @@ -99,8 +100,12 @@ func init() { challDetailsCmd.PersistentFlags().StringVarP(&Tags, "tags", "t", "", "Filter by tagname : pwn / web / image / docker") restoreDatabaseCmd.PersistentFlags().StringVarP(&RestoreFile, "restore-file", "r", "", "Backup file to be used for restoration.") + resetDatabaseCmd.Flags().BoolVar(&ConfirmDestructive, "yes", false, "Confirm destructive database reset") + restoreDatabaseCmd.Flags().BoolVar(&ConfirmDestructive, "yes", false, "Confirm destructive database restore") restoreCacheCmd.PersistentFlags().StringVarP(&RestoreFile, "restore-file", "r", "", "Restore file to be used for restoration.") + resetCacheCmd.Flags().BoolVar(&ConfirmDestructive, "yes", false, "Confirm destructive cache reset") + restoreCacheCmd.Flags().BoolVar(&ConfirmDestructive, "yes", false, "Confirm destructive cache restore") rootCmd.AddCommand(versionCmd) rootCmd.AddCommand(initCmd) diff --git a/cmd/beast/database.go b/cmd/beast/database.go index cc5a2122..31e384b6 100644 --- a/cmd/beast/database.go +++ b/cmd/beast/database.go @@ -1,30 +1,45 @@ package main import ( + "fmt" + "github.com/sdslabs/beastv4/core/database" - log "github.com/sirupsen/logrus" "github.com/spf13/cobra" ) var resetDatabaseCmd = &cobra.Command{ Use: "reset-database", - Short: "Backups the existing database and cleans up old db and remote/staging directories", - Run: func(cmd *cobra.Command, args []string) { - database.BackupAndReset() + Short: "Backs up and resets the configured PostgreSQL database", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if err := requireDestructiveConfirmation(); err != nil { + return err + } + cleanup, err := initializeMaintenance(false) + if err != nil { + return err + } + defer cleanup() + return database.BackupAndReset() }, } var restoreDatabaseCmd = &cobra.Command{ Use: "restore-database", - Short: "Restores the database, with the backed-up file", - Run: func(cmd *cobra.Command, args []string) { - if RestoreFile != "" { - err := database.RestoreDatabase(RestoreFile) - if err != nil { - log.Errorf("Error restoring database from file %s: %v\n", RestoreFile, err) - } - } else { - log.Fatalf("Restore file not specified.") + Short: "Restores the configured PostgreSQL database from a backup", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if err := requireDestructiveConfirmation(); err != nil { + return err + } + if RestoreFile == "" { + return fmt.Errorf("restore file is required") + } + cleanup, err := initializeMaintenance(false) + if err != nil { + return err } + defer cleanup() + return database.RestoreDatabase(RestoreFile) }, } diff --git a/cmd/beast/maintenance.go b/cmd/beast/maintenance.go new file mode 100644 index 00000000..c62c5b98 --- /dev/null +++ b/cmd/beast/maintenance.go @@ -0,0 +1,31 @@ +package main + +import ( + "fmt" + + "github.com/sdslabs/beastv4/core" + "github.com/sdslabs/beastv4/core/cache" + "github.com/sdslabs/beastv4/core/config" +) + +func initializeMaintenance(useCache bool) (func(), error) { + if err := config.InitConfig(); err != nil { + return nil, err + } + lock, err := acquireControllerLock(core.BEAST_GLOBAL_DIR) + if err != nil { + return nil, fmt.Errorf("maintenance requires an exclusive controller lock: %w", err) + } + if useCache { + redis := config.Cfg.RedisConf + cache.Configure(redis.User, redis.Password, redis.Host, redis.Port, redis.Db, redis.TLS, redis.CAFile, redis.ServerName) + } + return func() { releaseControllerLock(lock) }, nil +} + +func requireDestructiveConfirmation() error { + if !ConfirmDestructive { + return fmt.Errorf("destructive operation requires --yes") + } + return nil +} diff --git a/cmd/beast/maintenance_test.go b/cmd/beast/maintenance_test.go new file mode 100644 index 00000000..83e741e9 --- /dev/null +++ b/cmd/beast/maintenance_test.go @@ -0,0 +1,17 @@ +package main + +import "testing" + +func TestDestructiveMaintenanceRequiresConfirmation(t *testing.T) { + previous := ConfirmDestructive + ConfirmDestructive = false + defer func() { ConfirmDestructive = previous }() + + if err := requireDestructiveConfirmation(); err == nil { + t.Fatal("expected destructive operation without --yes to fail") + } + ConfirmDestructive = true + if err := requireDestructiveConfirmation(); err != nil { + t.Fatalf("expected confirmed destructive operation to pass: %v", err) + } +} diff --git a/core/cache/cache.go b/core/cache/cache.go index dac7dae9..f80d0bc5 100644 --- a/core/cache/cache.go +++ b/core/cache/cache.go @@ -203,53 +203,17 @@ func Close() error { return nil } -func BackupAndReset() { +func BackupAndReset() error { if err := LoadCacheConfig(); err != nil { - log.Error(err) - return - } - - err := BackupCache() - if err != nil { - log.Errorf("Error while backing up cache: %s", err) - return - } - err = ResetCache() - if err != nil { - log.Errorf("Error while resetting up cache: %s", err) - return - } - - backupPath := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_BACKUP_DIR, core.BEAST_REMOTES_DIR) - err = utils.CreateIfNotExistDir(backupPath) - if err != nil { - log.Errorf("Error while creating backup directory: %s", err) - return - } - - backupPath = filepath.Join(backupPath, core.BEAST_REMOTES_DIR+time.Now().Format("20060102150405")+".bak") - oldPath := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_REMOTES_DIR) - err = os.Rename(oldPath, backupPath) - if err != nil { - log.Errorf("Error while backing up remote dir: %s", err) - return + return err } - - backupPath = filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_BACKUP_DIR, core.BEAST_STAGING_DIR) - - err = utils.CreateIfNotExistDir(backupPath) - if err != nil { - log.Errorf("Error while creating backup directory: %s", err) - return + if err := BackupCache(); err != nil { + return fmt.Errorf("back up cache: %w", err) } - - oldPath = filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_STAGING_DIR) - backupPath = filepath.Join(backupPath, core.BEAST_STAGING_DIR+time.Now().Format("20060102150405")+".bak") - err = os.Rename(oldPath, backupPath) - if err != nil { - log.Errorf("Error while backing up staging dir: %s", err) - return + if err := ResetCache(); err != nil { + return fmt.Errorf("reset cache: %w", err) } + return nil } func BackupCache() error { @@ -289,12 +253,6 @@ func ResetCache() error { return err } } - err := TerminateCacheConnections() - if err != nil { - log.Errorf("Unable to terminate connections %s", err) - return err - } - args := append(redisCLIConnectionArgs(), "FLUSHDB") dropCmd := exec.Command("redis-cli", args...) @@ -310,45 +268,6 @@ func ResetCache() error { return nil } -// Terminate all active connections before dropping -func TerminateCacheConnections() error { - if cacheConfig == (RedisConfig{}) { - if err := LoadCacheConfig(); err != nil { - return err - } - } - - tlsConfig, err := NewTLSConfig(cacheConfig.TLS, cacheConfig.CAFile, cacheConfig.ServerName, cacheConfig.Host) - if err != nil { - return err - } - cache := redis.NewClient(&redis.Options{ - Addr: redisAddress(cacheConfig), - Username: core.REDIS_DEFAULT_USER, - Password: utils.PromptSecret("Enter default redis user password"), - TLSConfig: tlsConfig, - }) - - _, err = cache.Ping(context.Background()).Result() - if err != nil { - log.Errorf("Terminate connections error: %s\n", err.Error()) - } - - defer cache.Close() - - _, err = cache.Do(context.Background(), - "CLIENT", "KILL", - "USER", cacheConfig.User, - "SKIPME", "yes", - ).Result() - - if err != nil { - log.Errorf("Terminate connections error: %s\n", err.Error()) - } - - return nil -} - func redisCLIConnectionArgs() []string { args := []string{ "-h", cacheConfig.Host, @@ -375,5 +294,5 @@ func RestoreCache(backupFile string) error { The primary issue with restoring cache is that it needs to be written to /var/lib and redis needs to be restarted. Redis will then pick up the changes and continue from there. */ - return nil + return fmt.Errorf("Redis restore is not implemented; no data was changed") } diff --git a/core/database/database.go b/core/database/database.go index bb033184..72ad1a63 100644 --- a/core/database/database.go +++ b/core/database/database.go @@ -140,53 +140,17 @@ func Init() error { return nil } -func BackupAndReset() { +func BackupAndReset() error { if err := LoadDbConfig(); err != nil { - log.Error(err) - return - } - - err := BackupDatabase() - if err != nil { - log.Errorf("Error while backing up database: %s", err) - return - } - err = ResetDatabase() - if err != nil { - log.Errorf("Error while resetting up database: %s", err) - return - } - - backupPath := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_BACKUP_DIR, core.BEAST_REMOTES_DIR) - err = utils.CreateIfNotExistDir(backupPath) - if err != nil { - log.Errorf("Error while creating backup directory: %s", err) - return - } - - backupPath = filepath.Join(backupPath, core.BEAST_REMOTES_DIR+time.Now().Format("20060102150405")+".bak") - oldPath := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_REMOTES_DIR) - err = os.Rename(oldPath, backupPath) - if err != nil { - log.Errorf("Error while backing up remote dir: %s", err) - return + return err } - - backupPath = filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_BACKUP_DIR, core.BEAST_STAGING_DIR) - - err = utils.CreateIfNotExistDir(backupPath) - if err != nil { - log.Errorf("Error while creating backup directory: %s", err) - return + if err := BackupDatabase(); err != nil { + return fmt.Errorf("back up database: %w", err) } - - oldPath = filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_STAGING_DIR) - backupPath = filepath.Join(backupPath, core.BEAST_STAGING_DIR+time.Now().Format("20060102150405")+".bak") - err = os.Rename(oldPath, backupPath) - if err != nil { - log.Errorf("Error while backing up staging dir: %s", err) - return + if err := ResetDatabase(); err != nil { + return fmt.Errorf("reset database: %w", err) } + return nil } func BackupDatabase() error { @@ -221,12 +185,6 @@ func ResetDatabase() error { return err } } - err := TerminateDatabaseConnections() - if err != nil { - log.Errorf("Unable to terminate connections %s", err) - return err - } - dropCmd := exec.Command("dropdb", "-U", dbConfig.User, "-h", dbConfig.Host, "-p", dbConfig.Port, "--force", dbConfig.Dbname) dropCmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", dbConfig.Password)) @@ -248,46 +206,12 @@ func ResetDatabase() error { return nil } -// Terminate all active connections before dropping -func TerminateDatabaseConnections() error { - if dbConfig == (beastConfig.PsqlConfig{}) { - if err := LoadDbConfig(); err != nil { - return err - } - } - terminateCmd := exec.Command( - "psql", - "-U", dbConfig.User, - "-h", dbConfig.Host, - "-p", dbConfig.Port, - "-d", "postgres", - "-c", - fmt.Sprintf("SELECT pg_terminate_backend(pg_stat_activity.pid) FROM pg_stat_activity WHERE datname = %s AND pid <> pg_backend_pid();", utils.QuoteLiteral(dbConfig.Dbname)), - ) - terminateCmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", dbConfig.Password)) - - output, err := terminateCmd.CombinedOutput() - outputStr := string(output) - if err != nil { - log.Errorf("Terminate connections error: %s\n", outputStr) - return err - } - log.Debug(outputStr) - return nil -} - func RestoreDatabase(backupFile string) error { if err := LoadDbConfig(); err != nil { return err } - err := TerminateDatabaseConnections() - if err != nil { - log.Errorf("Unable to terminate connections: %s ", err) - return err - } - - err = utils.ValidateFileExists(backupFile) + err := utils.ValidateFileExists(backupFile) if err != nil { return fmt.Errorf("backup file does not exist: %s", backupFile) } From 593e09b561a1faa914a920f632bdcaf7e311ba11 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 14:43:53 +0530 Subject: [PATCH 109/149] fix(maintenance): bound external commands Signed-off-by: vibhatsu --- core/cache/cache.go | 20 ++++-------- core/database/database.go | 36 +++++++-------------- utils/command.go | 66 +++++++++++++++++++++++++++++++++++++++ utils/command_test.go | 14 +++++++++ 4 files changed, 97 insertions(+), 39 deletions(-) create mode 100644 utils/command.go create mode 100644 utils/command_test.go diff --git a/core/cache/cache.go b/core/cache/cache.go index f80d0bc5..43cfcd80 100644 --- a/core/cache/cache.go +++ b/core/cache/cache.go @@ -7,7 +7,6 @@ import ( "fmt" "net" "os" - "os/exec" "path/filepath" "strconv" "strings" @@ -235,13 +234,10 @@ func BackupCache() error { args := redisCLIConnectionArgs() args = append(args, "--rdb", filepath.Join(backupPath, backupFile)) - cmd := exec.Command("redis-cli", args...) - - cmd.Env = append(os.Environ(), fmt.Sprintf("REDISCLI_AUTH=%s", cacheConfig.Password)) - output, err := cmd.CombinedOutput() + environment := append(os.Environ(), fmt.Sprintf("REDISCLI_AUTH=%s", cacheConfig.Password)) + output, err := utils.RunCommand(30*time.Minute, environment, "redis-cli", args...) if err != nil { - log.Printf("Backup error: %s\n", string(output)) - return err + return fmt.Errorf("back up Redis: %w; output: %s", err, output) } log.Debug("Backup successful.") return nil @@ -254,14 +250,10 @@ func ResetCache() error { } } args := append(redisCLIConnectionArgs(), "FLUSHDB") - dropCmd := exec.Command("redis-cli", args...) - - dropCmd.Env = append(os.Environ(), fmt.Sprintf("REDISCLI_AUTH=%s", cacheConfig.Password)) - - output, err := dropCmd.CombinedOutput() + environment := append(os.Environ(), fmt.Sprintf("REDISCLI_AUTH=%s", cacheConfig.Password)) + output, err := utils.RunCommand(2*time.Minute, environment, "redis-cli", args...) if err != nil { - log.Printf("Drop Cache error: %s\n", string(output)) - return err + return fmt.Errorf("flush Redis database: %w; output: %s", err, output) } log.Debug("Reset successful.") diff --git a/core/database/database.go b/core/database/database.go index 72ad1a63..24f3411a 100644 --- a/core/database/database.go +++ b/core/database/database.go @@ -6,7 +6,6 @@ import ( "net" "net/url" "os" - "os/exec" "path/filepath" "sync" "time" @@ -168,12 +167,10 @@ func BackupDatabase() error { } backupFile := fmt.Sprintf("%s_%s.bak", dbConfig.Dbname, time.Now().Format("20060102150405")) - cmd := exec.Command("pg_dump", "-U", dbConfig.User, "-h", dbConfig.Host, "-p", dbConfig.Port, "-F", "c", "-f", filepath.Join(backupPath, backupFile), dbConfig.Dbname) - cmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", dbConfig.Password)) - output, err := cmd.CombinedOutput() + environment := append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", dbConfig.Password), "PGSSLMODE="+dbConfig.SslMode) + output, err := utils.RunCommand(30*time.Minute, environment, "pg_dump", "-U", dbConfig.User, "-h", dbConfig.Host, "-p", dbConfig.Port, "-F", "c", "-f", filepath.Join(backupPath, backupFile), dbConfig.Dbname) if err != nil { - log.Printf("Backup error: %s\n", string(output)) - return err + return fmt.Errorf("pg_dump failed: %w; output: %s", err, output) } log.Debug("Backup successful.") return nil @@ -185,22 +182,15 @@ func ResetDatabase() error { return err } } - dropCmd := exec.Command("dropdb", "-U", dbConfig.User, "-h", dbConfig.Host, "-p", dbConfig.Port, "--force", dbConfig.Dbname) - dropCmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", dbConfig.Password)) - - output, err := dropCmd.CombinedOutput() + environment := append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", dbConfig.Password), "PGSSLMODE="+dbConfig.SslMode) + output, err := utils.RunCommand(2*time.Minute, environment, "dropdb", "-U", dbConfig.User, "-h", dbConfig.Host, "-p", dbConfig.Port, "--force", dbConfig.Dbname) if err != nil { - log.Printf("Drop DB error: %s\n", string(output)) - return err + return fmt.Errorf("drop database: %w; output: %s", err, output) } - createCmd := exec.Command("psql", "-U", dbConfig.User, "-h", dbConfig.Host, "-p", dbConfig.Port, "-d", "postgres", "-c", "CREATE DATABASE "+pq.QuoteIdentifier(dbConfig.Dbname)+";") - createCmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", dbConfig.Password)) - - output, err = createCmd.CombinedOutput() + output, err = utils.RunCommand(2*time.Minute, environment, "psql", "-U", dbConfig.User, "-h", dbConfig.Host, "-p", dbConfig.Port, "-d", "postgres", "-c", "CREATE DATABASE "+pq.QuoteIdentifier(dbConfig.Dbname)+";") if err != nil { - log.Printf("Create DB error: %s\n", string(output)) - return err + return fmt.Errorf("create database: %w; output: %s", err, output) } log.Debug("Reset successful.") return nil @@ -216,8 +206,8 @@ func RestoreDatabase(backupFile string) error { return fmt.Errorf("backup file does not exist: %s", backupFile) } - restoreCmd := exec.Command( - "pg_restore", + environment := append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", dbConfig.Password), "PGSSLMODE="+dbConfig.SslMode) + output, err := utils.RunCommand(30*time.Minute, environment, "pg_restore", "-U", dbConfig.User, "-h", dbConfig.Host, "-p", dbConfig.Port, @@ -227,12 +217,8 @@ func RestoreDatabase(backupFile string) error { "--if-exists", backupFile, ) - restoreCmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", dbConfig.Password)) - - output, err := restoreCmd.CombinedOutput() if err != nil { - log.Printf("Restore DB error: %s\n", string(output)) - return fmt.Errorf("failed to restore database from %s: %v", backupFile, err) + return fmt.Errorf("restore database from %s: %w; output: %s", backupFile, err, output) } log.Println("Database restored successfully from:", backupFile) diff --git a/utils/command.go b/utils/command.go new file mode 100644 index 00000000..a9646490 --- /dev/null +++ b/utils/command.go @@ -0,0 +1,66 @@ +package utils + +import ( + "bytes" + "context" + "errors" + "fmt" + "os/exec" + "sync" + "time" +) + +const maxCommandOutput = 64 << 10 + +var ErrCommandOutputLimit = errors.New("command output exceeds 64 KiB limit") + +type boundedCommandOutput struct { + mutex sync.Mutex + buffer bytes.Buffer + remaining int + truncated bool +} + +func (output *boundedCommandOutput) Write(data []byte) (int, error) { + output.mutex.Lock() + defer output.mutex.Unlock() + written := len(data) + if len(data) > output.remaining { + data = data[:output.remaining] + output.truncated = true + } + if len(data) > 0 { + _, _ = output.buffer.Write(data) + output.remaining -= len(data) + } + return written, nil +} + +func (output *boundedCommandOutput) String() string { + output.mutex.Lock() + defer output.mutex.Unlock() + return output.buffer.String() +} + +func RunCommand(timeout time.Duration, environment []string, name string, arguments ...string) (string, error) { + if timeout <= 0 { + return "", fmt.Errorf("command timeout must be positive") + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + command := exec.CommandContext(ctx, name, arguments...) + if environment != nil { + command.Env = environment + } + output := &boundedCommandOutput{remaining: maxCommandOutput} + command.Stdout = output + command.Stderr = output + err := command.Run() + if ctx.Err() != nil { + return output.String(), fmt.Errorf("command timed out after %s: %w", timeout, ctx.Err()) + } + if output.truncated { + return output.String(), ErrCommandOutputLimit + } + return output.String(), err +} diff --git a/utils/command_test.go b/utils/command_test.go new file mode 100644 index 00000000..07dd37c0 --- /dev/null +++ b/utils/command_test.go @@ -0,0 +1,14 @@ +package utils + +import "testing" + +func TestBoundedCommandOutputEnforcesLimit(t *testing.T) { + output := &boundedCommandOutput{remaining: 4} + written, err := output.Write([]byte("tests")) + if err != nil { + t.Fatal(err) + } + if written != 5 || output.String() != "test" || !output.truncated { + t.Fatalf("unexpected bounded output: written=%d output=%q truncated=%t", written, output.String(), output.truncated) + } +} From c534b41c10581956b6f211ff3b50e44108976633 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 14:50:01 +0530 Subject: [PATCH 110/149] fix(setup): remove remote script execution Signed-off-by: vibhatsu --- cmd/beast/init.go | 79 ++++++++++---------------------------- cmd/beast/tls.go | 88 +++++++++++++++++++++++++++++++++++++++++++ cmd/beast/tls_test.go | 31 +++++++++++++++ 3 files changed, 138 insertions(+), 60 deletions(-) create mode 100644 cmd/beast/tls.go create mode 100644 cmd/beast/tls_test.go diff --git a/cmd/beast/init.go b/cmd/beast/init.go index a07d3019..ab167649 100644 --- a/cmd/beast/init.go +++ b/cmd/beast/init.go @@ -5,14 +5,10 @@ import ( "database/sql" "errors" "fmt" - "io" "net" - "net/http" "os" - "os/exec" "os/user" "path/filepath" - "strings" "time" _ "github.com/jackc/pgx/v5/stdlib" @@ -21,7 +17,6 @@ import ( "github.com/sdslabs/beastv4/core" coreCache "github.com/sdslabs/beastv4/core/cache" "github.com/sdslabs/beastv4/core/config" - "github.com/sdslabs/beastv4/core/database" coreUtils "github.com/sdslabs/beastv4/core/utils" "github.com/sdslabs/beastv4/utils" log "github.com/sirupsen/logrus" @@ -39,6 +34,7 @@ func initDirectories() error { log.Infoln("Creating beast directories...") directories := []string{ + core.BEAST_GLOBAL_DIR, filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_CACHE_DIR), filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_REMOTES_DIR), filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_UPLOADS_DIR), @@ -49,10 +45,13 @@ func initDirectories() error { } for _, dir := range directories { - err := os.MkdirAll(dir, 0755) + err := os.MkdirAll(dir, 0700) if err != nil { return err } + if err := os.Chmod(dir, 0700); err != nil { + return err + } } return nil @@ -60,43 +59,11 @@ func initDirectories() error { func checkDockerDaemon() error { log.Infoln("Checking docker daemon...") - _, err := os.Stat(core.DOCKER_PID) - return err -} - -func installAir() error { - log.Infoln("Installing air for live reloading...") - - resp, err := http.Get("https://raw.githubusercontent.com/cosmtrek/air/master/install.sh") + output, err := utils.RunCommand(30*time.Second, nil, "docker", "info", "--format", "{{.ServerVersion}}") if err != nil { - return err + return fmt.Errorf("Docker daemon is unavailable: %w; output: %s", err, output) } - defer resp.Body.Close() - - install := "install.sh" - out, err := os.Create(install) - if err != nil { - return err - } - defer out.Close() - - defer os.Remove(install) - - if _, err = io.Copy(out, resp.Body); err != nil { - return err - } - - gopath, err := exec.Command("go", "env", "GOPATH").Output() - if err != nil { - return err - } - binDir := filepath.Join(strings.TrimSpace(string(gopath)), "bin") - - cmd := exec.Command("sh", install, "-b", binDir) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - return cmd.Run() + return nil } func createBeastRedisUser(cache *redis.Client, configuration *config.RedisConfig) error { @@ -298,12 +265,9 @@ func initAdmin() error { } password := utils.PromptSecret("Enter admin password") - if password == "" { - return errors.New("admin password is required") - } - - if err := database.Init(); err != nil { - return err + confirmation := utils.PromptSecret("Confirm admin password") + if password != confirmation || len(password) < 12 || len(password) > 128 { + return errors.New("admin password confirmation must match and contain 12 to 128 bytes") } if err := createAuthorAdminPrereq(); err != nil { @@ -331,18 +295,15 @@ func runBeastBootsteps() error { } log.Infoln(fmt.Sprintf("Beast global config file initiliazed at %s", BEAST_GLOBAL_CONFIG)) - - if err := checkDockerDaemon(); err != nil { + if err := ensureLocalTLSCertificate(); err != nil { return err } - log.Infoln("Verified Docker Daemon running") - - if err := installAir(); err != nil { + if err := checkDockerDaemon(); err != nil { return err } - log.Infoln("Successfully installed air for live reloading...") + log.Infoln("Verified Docker Daemon running") if err := config.InitConfig(); err != nil { return err @@ -374,18 +335,16 @@ var initCmd = &cobra.Command{ Short: "Run Beast initial setup bootsetps.", Long: "Initializes beast by setting up beast directory, checking for permission. It also configures the logger and local SQLite database to be used by beast", - Run: func(cmd *cobra.Command, args []string) { - err := runBeastBootsteps() - - if err != nil { - log.Errorln(err.Error()) - log.Errorln("Failed to complete beast bootsteps... fix above errors and try again") - return + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if err := runBeastBootsteps(); err != nil { + return fmt.Errorf("complete Beast bootsteps: %w", err) } log.Infoln(COLOR_GREEN + "Please run beast server by following command:-" + RESET) log.Infoln(COLOR_GREEN + "******************" + RESET) log.Infoln(COLOR_GREEN + "* " + BLINK_ON + "beast run -v" + BLINK_OFF + " *" + RESET) log.Infoln(COLOR_GREEN + "******************" + RESET) + return nil }, } diff --git a/cmd/beast/tls.go b/cmd/beast/tls.go new file mode 100644 index 00000000..fa0da1e1 --- /dev/null +++ b/cmd/beast/tls.go @@ -0,0 +1,88 @@ +package main + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "math/big" + "net" + "os" + "path/filepath" + "time" + + "github.com/sdslabs/beastv4/core" +) + +func ensureLocalTLSCertificate() error { + certPath := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_SECRETS_DIR, "tls.crt") + keyPath := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_SECRETS_DIR, "tls.key") + certInfo, certErr := os.Lstat(certPath) + keyInfo, keyErr := os.Lstat(keyPath) + if certErr == nil && keyErr == nil { + if !certInfo.Mode().IsRegular() || !keyInfo.Mode().IsRegular() || keyInfo.Mode().Perm() != 0600 { + return fmt.Errorf("existing TLS certificate and key must be regular files and the key must be 0600") + } + return nil + } + if !os.IsNotExist(certErr) || !os.IsNotExist(keyErr) || os.IsNotExist(certErr) != os.IsNotExist(keyErr) { + return fmt.Errorf("TLS certificate and key must either both exist or both be absent") + } + + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return fmt.Errorf("generate TLS private key: %w", err) + } + serialLimit := new(big.Int).Lsh(big.NewInt(1), 128) + serial, err := rand.Int(rand.Reader, serialLimit) + if err != nil { + return fmt.Errorf("generate certificate serial: %w", err) + } + now := time.Now() + template := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{CommonName: "localhost"}, + NotBefore: now.Add(-5 * time.Minute), + NotAfter: now.AddDate(1, 0, 0), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + DNSNames: []string{"localhost"}, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")}, + } + certificate, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey) + if err != nil { + return fmt.Errorf("create TLS certificate: %w", err) + } + keyBytes, err := x509.MarshalPKCS8PrivateKey(privateKey) + if err != nil { + return fmt.Errorf("marshal TLS private key: %w", err) + } + if err := writeExclusivePEM(keyPath, 0600, "PRIVATE KEY", keyBytes); err != nil { + return err + } + if err := writeExclusivePEM(certPath, 0644, "CERTIFICATE", certificate); err != nil { + _ = os.Remove(keyPath) + return err + } + return nil +} + +func writeExclusivePEM(path string, mode os.FileMode, blockType string, contents []byte) error { + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode) + if err != nil { + return fmt.Errorf("create %s: %w", path, err) + } + if err := pem.Encode(file, &pem.Block{Type: blockType, Bytes: contents}); err != nil { + file.Close() + _ = os.Remove(path) + return fmt.Errorf("write %s: %w", path, err) + } + if err := file.Close(); err != nil { + _ = os.Remove(path) + return fmt.Errorf("close %s: %w", path, err) + } + return nil +} diff --git a/cmd/beast/tls_test.go b/cmd/beast/tls_test.go new file mode 100644 index 00000000..8ad12eac --- /dev/null +++ b/cmd/beast/tls_test.go @@ -0,0 +1,31 @@ +package main + +import ( + "crypto/tls" + "os" + "path/filepath" + "testing" + + "github.com/sdslabs/beastv4/core" +) + +func TestEnsureLocalTLSCertificate(t *testing.T) { + previous := core.BEAST_GLOBAL_DIR + core.BEAST_GLOBAL_DIR = t.TempDir() + defer func() { core.BEAST_GLOBAL_DIR = previous }() + if err := os.MkdirAll(filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_SECRETS_DIR), 0700); err != nil { + t.Fatal(err) + } + if err := ensureLocalTLSCertificate(); err != nil { + t.Fatal(err) + } + certPath := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_SECRETS_DIR, "tls.crt") + keyPath := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_SECRETS_DIR, "tls.key") + if _, err := tls.LoadX509KeyPair(certPath, keyPath); err != nil { + t.Fatalf("load generated key pair: %v", err) + } + info, err := os.Stat(keyPath) + if err != nil || info.Mode().Perm() != 0600 { + t.Fatalf("unexpected key permissions: %v, %v", info, err) + } +} From 71143fd877093932fa4f3be6d51dc26d6239a23a Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 14:52:08 +0530 Subject: [PATCH 111/149] fix(config): generate setup credentials locally Signed-off-by: vibhatsu --- cmd/beast/config.go | 173 +++++++++++++++++++++------------------ cmd/beast/config_test.go | 17 ++++ 2 files changed, 112 insertions(+), 78 deletions(-) create mode 100644 cmd/beast/config_test.go diff --git a/cmd/beast/config.go b/cmd/beast/config.go index 4d410b89..b7d75a14 100644 --- a/cmd/beast/config.go +++ b/cmd/beast/config.go @@ -1,57 +1,35 @@ package main import ( - "errors" + "bytes" + "crypto/rand" + "encoding/base64" "fmt" + "os" + "path/filepath" + "strconv" + "time" + "github.com/BurntSushi/toml" "github.com/sdslabs/beastv4/core" "github.com/sdslabs/beastv4/core/config" "github.com/sdslabs/beastv4/utils" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" - "io" - "net/http" - "os" - "path/filepath" - "strconv" - "time" ) var ( MINIMUM_MEMORY_LIMIT int64 = (1 << 23) /* a little over 6MB */ BEAST_GLOBAL_CONFIG string = filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_CONFIG_FILE_NAME) - - BEAST_EXAMPLE_DIRECTORY string = filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_EXAMPLE_DIR) - BEAST_EXAMPLE_CONFIG string = filepath.Join(BEAST_EXAMPLE_DIRECTORY, core.BEAST_EX_CONFIG_FILE_NAME) ) -func downloadExampleBeastConfig() error { - log.Println("Downloading example config file from GitHub...") - - response, err := http.Get("https://raw.githubusercontent.com/sdslabs/beast/master/_examples/example.config.toml") - if err != nil { - return err - } - defer response.Body.Close() - - if response.StatusCode != http.StatusOK { - return errors.New("error while downloading: " + response.Status) +func generateConfigSecret(size int) (string, error) { + secret := make([]byte, size) + if _, err := rand.Read(secret); err != nil { + return "", err } - - err = os.MkdirAll(BEAST_EXAMPLE_DIRECTORY, 0755) - if err != nil { - return err - } - - exampleConfig, err := os.Create(BEAST_EXAMPLE_CONFIG) - if err != nil { - return err - } - defer exampleConfig.Close() - - _, err = io.Copy(exampleConfig, response.Body) - return err + return base64.RawURLEncoding.EncodeToString(secret), nil } func promptServerDetails(configuration *config.BeastConfig) { @@ -71,7 +49,7 @@ func promptServerDetails(configuration *config.BeastConfig) { } func promptResourceLimits(configuration *config.BeastConfig) { - configuration.CPUShares = utils.PromptInt64("Default CPU Share (must be over 6MB):", core.DEFAULT_CPU_SHARE) + configuration.CPUShares = utils.PromptInt64("Default CPU shares", core.DEFAULT_CPU_SHARE) configuration.CPUsLimit = utils.PromptFloat32("Default CPU Limit", core.DEFAULT_CPU_LIMIT) configuration.PidsLimit = utils.PromptInt64("Default PIDs Limit:", core.DEFAULT_PIDS_LIMIT) configuration.Memory = utils.PromptInt64("Default Memory Limit:", core.DEFAULT_MEMORY_LIMIT) @@ -90,7 +68,7 @@ func promptRemoteRepository(configuration *config.BeastConfig) { remote.Active = utils.PromptBinary("Enable this repository?") remote.RemoteName = utils.PromptString("Remote Repository Name") remote.Branch = utils.PromptString("Remote Repository Branch") - remote.Secret = utils.PromptSecret("Remote Repository SSH Key") + remote.Secret = utils.PromptString("Path to remote repository SSH private key") err := remote.ValidateGitConfig() if err != nil { @@ -104,6 +82,9 @@ func promptRemoteRepository(configuration *config.BeastConfig) { } func promptCompetitionDetails(configuration *config.BeastConfig) { + if !utils.PromptBinary("Configure competition metadata?") { + return + } configuration.CompetitionInfo.Name = utils.PromptString("Enter Competition Name") configuration.CompetitionInfo.About = utils.PromptString("Enter Competition About Text") configuration.CompetitionInfo.Prizes = utils.PromptString("Enter Competition Prizes Text") @@ -140,15 +121,20 @@ func promptNotificationWebhooks(configuration *config.BeastConfig) { } } -func promptCacheConnectionDetails(configuration *config.BeastConfig) { +func promptCacheConnectionDetails(configuration *config.BeastConfig) error { configuration.RedisConf.User = utils.PromptString("Enter Redis User Name (this user will be created if does not exist)... leaving it empty will default it to beast") if configuration.RedisConf.User == "" { configuration.RedisConf.User = "beast" } - configuration.RedisConf.Password = utils.PromptSecret(fmt.Sprintf("Enter Redis User %s Password... leaving it empty will default it to beast", configuration.RedisConf.User)) + configuration.RedisConf.Password = utils.PromptSecret(fmt.Sprintf("Enter Redis user %s password (leave empty to generate one)", configuration.RedisConf.User)) if configuration.RedisConf.Password == "" { - configuration.RedisConf.Password = "beast" + password, err := generateConfigSecret(32) + if err != nil { + return err + } + configuration.RedisConf.Password = password + log.Info("Generated a Redis password in the private Beast configuration") } configuration.RedisConf.Host = utils.PromptString("Enter Redis Host Name, leave empty for localhost") @@ -157,12 +143,18 @@ func promptCacheConnectionDetails(configuration *config.BeastConfig) { } configuration.RedisConf.Port = strconv.FormatInt(utils.PromptInt64("Enter Redis Port", 6379), 10) + configuration.RedisConf.TLS = utils.PromptBinary("Use TLS for Redis?") + if configuration.RedisConf.TLS { + configuration.RedisConf.CAFile = utils.PromptString("Redis CA certificate path (empty uses system roots)") + configuration.RedisConf.ServerName = utils.PromptString("Redis TLS server name (empty uses host)") + } log.Infoln("Setting Redis DB to 0...") configuration.RedisConf.Db = 0 + return nil } -func promptDatabaseConnectionDetails(configuration *config.BeastConfig) { +func promptDatabaseConnectionDetails(configuration *config.BeastConfig) error { configuration.PsqlConf.User = utils.PromptString("Enter Postgres User Name (this user will be created if does not exist)... leaving it empty will default it to beast") if configuration.PsqlConf.User == "" { configuration.PsqlConf.User = "beast" @@ -173,9 +165,14 @@ func promptDatabaseConnectionDetails(configuration *config.BeastConfig) { configuration.PsqlConf.Dbname = "beast" } - configuration.PsqlConf.Password = utils.PromptSecret(fmt.Sprintf("Enter Postgres User %s Password... leaving it empty will default it to beast", configuration.PsqlConf.User)) + configuration.PsqlConf.Password = utils.PromptSecret(fmt.Sprintf("Enter Postgres user %s password (leave empty to generate one)", configuration.PsqlConf.User)) if configuration.PsqlConf.Password == "" { - configuration.PsqlConf.Password = "beast" + password, err := generateConfigSecret(32) + if err != nil { + return err + } + configuration.PsqlConf.Password = password + log.Info("Generated a PostgreSQL password in the private Beast configuration") } configuration.PsqlConf.Host = utils.PromptString("Enter Postgres Host Name, leave empty for localhost") @@ -188,44 +185,63 @@ func promptDatabaseConnectionDetails(configuration *config.BeastConfig) { "allow", "prefer", "require", + "verify-ca", + "verify-full", }) + return nil } -func promptBeastConfiguration(configuration *config.BeastConfig) { +func promptBeastConfiguration(configuration *config.BeastConfig) error { promptServerDetails(configuration) promptResourceLimits(configuration) promptRemoteRepository(configuration) promptCompetitionDetails(configuration) promptNotificationWebhooks(configuration) - promptCacheConnectionDetails(configuration) - promptDatabaseConnectionDetails(configuration) + if err := promptCacheConnectionDetails(configuration); err != nil { + return err + } + return promptDatabaseConnectionDetails(configuration) } func tryCopyExampleConfig() error { - var configuration config.BeastConfig - - if _, err := os.Stat(BEAST_EXAMPLE_CONFIG); os.IsNotExist(err) { - log.Println("No example config file found...") - - if err = downloadExampleBeastConfig(); err != nil { - return err - } - } - - log.Println("Reading example config file at", BEAST_EXAMPLE_CONFIG) - - data, err := os.ReadFile(BEAST_EXAMPLE_CONFIG) + jwtSecret, err := generateConfigSecret(48) if err != nil { return err } - - if _, err = toml.Decode(string(data), &configuration); err != nil { + configuration := config.BeastConfig{ + AllowedBaseImages: []string{"ubuntu:24.04", "debian:bookworm"}, + AvailableServers: map[string]config.AvailableServer{ + core.LOCALHOST: {Host: core.LOCALHOST, Active: true, PortRange: "10000:20000"}, + }, + JWTSecret: jwtSecret, + TickerFrequency: core.DEFAULT_TICKER_FREQUENCY, + CPUShares: core.DEFAULT_CPU_SHARE, + CPUsLimit: core.DEFAULT_CPU_LIMIT, + Memory: core.DEFAULT_MEMORY_LIMIT, + PidsLimit: core.DEFAULT_PIDS_LIMIT, + PsqlConf: config.PsqlConfig{User: "beast", Dbname: "beast", Host: core.LOCALHOST, Port: "5432", SslMode: "disable"}, + RedisConf: config.RedisConfig{User: "beast", Host: core.LOCALHOST, Port: "6379"}, + InstanceConfig: config.InstanceConfig{DefaultExpiration: 300, MaxExtension: 600, MaxInstancesPerUser: 3}, + ServerConfig: config.ServerConfig{ + TLSCertFile: filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_SECRETS_DIR, "tls.crt"), + TLSKeyFile: filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_SECRETS_DIR, "tls.key"), + }, + } + if err := promptBeastConfiguration(&configuration); err != nil { return err } + if err := ensureLocalTLSCertificate(); err != nil { + return err + } + if err := configuration.ValidateConfig(); err != nil { + return fmt.Errorf("validate generated configuration: %w", err) + } - promptBeastConfiguration(&configuration) - - file, err := os.OpenFile(BEAST_GLOBAL_CONFIG, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + var encoded bytes.Buffer + if err := toml.NewEncoder(&encoded).Encode(configuration); err != nil { + return err + } + file, err := os.OpenFile(BEAST_GLOBAL_CONFIG, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) if err != nil { return err } @@ -233,19 +249,22 @@ func tryCopyExampleConfig() error { file.Close() return err } - defer file.Close() - - encoder := toml.NewEncoder(file) - if err = encoder.Encode(configuration); err != nil { + if _, err := file.Write(encoded.Bytes()); err != nil { + file.Close() + _ = os.Remove(BEAST_GLOBAL_CONFIG) return err } - - return nil + return file.Close() } func initBeastConfig() error { - if _, err := os.Stat(BEAST_GLOBAL_CONFIG); os.IsNotExist(err) { + if _, err := os.Lstat(BEAST_GLOBAL_CONFIG); os.IsNotExist(err) { + if err := initDirectories(); err != nil { + return err + } return tryCopyExampleConfig() + } else if err != nil { + return err } log.Infoln("Found global config file:", BEAST_GLOBAL_CONFIG) @@ -257,15 +276,13 @@ var configCmd = &cobra.Command{ Short: "Run interactive beast configuration setup", Long: "Creates the global Beast config file while prompting the user interactively whenever needed.", - Run: func(cmd *cobra.Command, args []string) { - err := initBeastConfig() - - if err != nil { - log.Errorln(err.Error()) - log.Errorln("Failed to create global beast config file... fix the above errors and try again") - return + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if err := initBeastConfig(); err != nil { + return fmt.Errorf("initialize Beast configuration: %w", err) } log.Infoln(fmt.Sprintf("Beast global config file initiliazed at %s", BEAST_GLOBAL_CONFIG)) + return nil }, } diff --git a/cmd/beast/config_test.go b/cmd/beast/config_test.go new file mode 100644 index 00000000..c729cac3 --- /dev/null +++ b/cmd/beast/config_test.go @@ -0,0 +1,17 @@ +package main + +import "testing" + +func TestGenerateConfigSecret(t *testing.T) { + first, err := generateConfigSecret(32) + if err != nil { + t.Fatal(err) + } + second, err := generateConfigSecret(32) + if err != nil { + t.Fatal(err) + } + if len(first) < 32 || first == second { + t.Fatalf("generated secrets are not sufficiently distinct: %q %q", first, second) + } +} From c9aad70731493416d8574ab9050c6b621cc7dc91 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 14:57:32 +0530 Subject: [PATCH 112/149] ci: modernize validation workflow Signed-off-by: vibhatsu --- Makefile | 15 ++- azure-pipelines.yml | 39 ++----- scripts/build/check_gofmt.sh | 14 +-- scripts/test/test_examples.sh | 200 ++++++---------------------------- 4 files changed, 59 insertions(+), 209 deletions(-) diff --git a/Makefile b/Makefile index 51fcdad9..e26a47d9 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ GO := go -AIR := ${GOPATH}/bin/air +AIR := $(shell $(GO) env GOPATH)/bin/air pkgs = $(shell $(GO) list ./... | grep -v vendor) @@ -33,8 +33,15 @@ check_format: # Add more tests later on for this test: check_format - @echo "[*] Running tests for example challenges" - @./scripts/test/test_examples.sh + @echo "[*] Running unit tests" + @$(GO) test ./... + +test-race: + @echo "[*] Running race-enabled tests" + @$(GO) test -race ./... + +integration-test: build + @BEAST_RUN_INTEGRATION=1 ./scripts/test/test_examples.sh # Format code using gofmt format: @@ -60,4 +67,4 @@ installenv: @echo 'Setting up environment for beast.' @./scripts/installenv.sh -.PHONY: build format test check_format docs installenv +.PHONY: build format test test-race integration-test check_format docs installenv govet diff --git a/azure-pipelines.yml b/azure-pipelines.yml index d48d602b..2496ea36 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -1,52 +1,27 @@ trigger: +- main - master pr: +- main - master - releases/* pool: vmImage: 'ubuntu-latest' -variables: - GOPATH: '$(system.defaultWorkingDirectory)/gopath' - steps: - task: GoTool@0 inputs: - version: '1.13.5' + version: '1.23.0' - script: | - sudo apt update - sudo apt install docker.io - sudo usermod -a -G docker $USER - sudo systemctl unmask docker - sudo systemctl start docker - displayName: 'Set up the Docker workspace' + make check_format + go vet ./... + go test -race ./... + displayName: 'Validate and test' workingDirectory: '$(System.DefaultWorkingDirectory)' -- script: | - mkdir ~/.beast - cp _examples/example.config.toml ~/.beast/config.toml - touch ~/.beast/secret.key - touch ~/.beast/authorized_keys - mkdir ~/.beast/staging - mkdir ~/.beast/remote - mkdir ~/.beast/scripts - displayName: 'Setup Beast Global Directory' - - script: | make build workingDirectory: '$(System.DefaultWorkingDirectory)' displayName: 'Build Beast' - -- script: | - make requirements - workingDirectory: '$(System.DefaultWorkingDirectory)' - displayName: 'Build Requirements' - -- script: | - echo -ne "ssh-rsa AAAAB3NzaC1y" > pub.key - $GOPATH/bin/beast create-author --name fristonio --email contact+fristonio@sdslabs.co.in --publickey pub.key -v --username fristonio --password pass123 - make test - workingDirectory: '$(System.DefaultWorkingDirectory)' - displayName: 'Run tests' diff --git a/scripts/build/check_gofmt.sh b/scripts/build/check_gofmt.sh index 778f3e36..6a368beb 100755 --- a/scripts/build/check_gofmt.sh +++ b/scripts/build/check_gofmt.sh @@ -1,14 +1,12 @@ -#!/bin/bash +#!/usr/bin/env bash -# Check the errors in formatting using gofmt -# Check formatting on non Godep'd code. -GOFMT_PATHS=$(find . -not -wholename "*.git*" -not -wholename "*Godeps*" -not -wholename "*gopath*" -not -wholename "*vendor*" -not -name "." -type d) +set -euo pipefail -# Find any files with gofmt problems -BAD_FILES=$(gofmt -s -l $GOFMT_PATHS) +mapfile -t go_files < <(git ls-files '*.go') +bad_files=$(gofmt -s -l "${go_files[@]}") -if [ -n "$BAD_FILES" ]; then +if [[ -n "${bad_files}" ]]; then echo "The following files are not properly formatted:" - echo $BAD_FILES + printf '%s\n' "${bad_files}" exit 1 fi diff --git a/scripts/test/test_examples.sh b/scripts/test/test_examples.sh index 092da832..6fee64e0 100755 --- a/scripts/test/test_examples.sh +++ b/scripts/test/test_examples.sh @@ -1,176 +1,46 @@ -#!/bin/bash +#!/usr/bin/env bash -PWD=$(pwd) +set -euo pipefail -ERROR="\e[31;1mERROR\e[0m" -SUCCESS="\e[32;1mSUCCESS\e[0m" -INFO="\e[34;1mINFO\e[0m" - -BEAST="${GOPATH}/bin/beast" - -checkPortAvailable() { - local challenge=$1 - local port=$2 - echo -e "$INFO : $challenge : Check port availability" - (echo -e 1 > /dev/tcp/127.0.0.1/$port) 2> /dev/null - if [[ $? -eq 0 ]]; then - echo -e "$ERROR : $challenge : port $port is not free, cannot test challenge" - exit 1 - fi -} - -deployChallenge() { - local challenge=$1 - local challdir=$2 - echo -e "$INFO : $challenge : Start deploy" - $BEAST -v challenge deploy --local-directory $challdir - if [[ $? -ne 0 ]]; then - echo -e "$ERROR: $challenge : There was an error in deployment of challenge" - exit 1 - fi -} - -checkPortReachable() { - local port=$1 - (echo -e 1 > /dev/tcp/127.0.0.1/$port) 2> /dev/null -} - -purge() { - local challenge=$1 - echo -e "$INFO : $challenge : Purge challenge" - $BEAST -v challenge purge $challenge -d - if [[ $? -ne 0 ]]; then - echo -e "$ERROR : $challenge : Error while purging" - exit 1 - fi -} - -doHTTPProbe() { - local url=$1 - curl --write-out %{http_code} --silent --output /dev/null $url -} - -# Test challenge simple -CHALLENGE="simple" -PORT=10001 -## Check if port is taken -checkPortAvailable $CHALLENGE $PORT -## Deploy challenge -deployChallenge $CHALLENGE $PWD/_examples/$CHALLENGE -## Test deployment -echo -e "$INFO : $CHALLENGE : Test deployment" -checkPortReachable $PORT -if [[ $? -eq 0 ]]; then - echo -e "$SUCCESS: $CHALLENGE : Deployed successfully" -else - echo -e "$ERROR: $CHALLENGE : There was an error in deployment of challenge" - exit 1 +if [[ "${BEAST_RUN_INTEGRATION:-}" != "1" ]]; then + echo "integration tests are destructive; run 'make integration-test' explicitly" >&2 + exit 2 fi -#Test challenge static-chall -CHALLENGE="static-chall" -#Test beast-static container -echo -e "$INFO : $CHALLENGE : Test beast-static container" -docker ps | grep -q 'beast-static' -if [[ $? -ne 0 ]]; then - echo -e "$ERROR: $CHALLENGE : beast-static container is not running" - exit 1 -fi -## Deploy challenge -deployChallenge $CHALLENGE $PWD/_examples/$CHALLENGE -## Test deployment -echo -e "$INFO : $CHALLENGE : Test deployment" -response_code=$(doHTTPProbe "http://localhost/static/$CHALLENGE/index.html") -if [[ $response_code -eq 200 ]]; then - echo -e "$SUCCESS : $CHALLENGE : Deployed successfully" -else - echo -e "$ERROR : $CHALLENGE : There was an error in deployment of challenge" - exit 1 -fi +repo_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) +beast_bin=${BEAST_BIN:-"$(go env GOPATH)/bin/beast"} +challenge_name="simple" +challenge_dir="${repo_dir}/_examples/${challenge_name}" +challenge_port=10005 +deployed=false -# Test challenge web-php -CHALLENGE="web-php" -PORT=10002 -## Check if port is taken -checkPortAvailable $CHALLENGE $PORT -## Deploy challenge -deployChallenge $CHALLENGE $PWD/_examples/$CHALLENGE -## Test deployment -echo -e "$INFO : $CHALLENGE : Test deployment" -response_code=$(doHTTPProbe "http://localhost:$PORT/index.php") -if [[ $response_code -eq 200 ]]; then - echo -e "$SUCCESS : $CHALLENGE : Deployed successfully" -else - echo -e "$ERROR : $CHALLENGE : There was an error in deployment of challenge" - exit 1 +if [[ ! -x "${beast_bin}" ]]; then + echo "Beast executable not found: ${beast_bin}" >&2 + exit 1 fi -# Test challenge web-php-mysql -CHALLENGE="web-php-mysql" -PORT=10004 -## Check if port is taken -checkPortAvailable $CHALLENGE $PORT -## Test beast-mysql container -echo -e "$INFO : $CHALLENGE : Test beast-mysql container" -docker ps | grep -q 'beast-mysql' -if [[ $? -ne 0 ]]; then - echo -e "$ERROR: $CHALLENGE : beast-mysql container is not running" - exit 1 -fi -## Deploy challenge -deployChallenge $CHALLENGE $PWD/_examples/$CHALLENGE -## Test deployment -echo -e "$INFO : $CHALLENGE : Test deployment" -response_code=$(doHTTPProbe "http://localhost:$PORT/index.php") -if [[ $response_code -eq 200 ]]; then - echo -e "$SUCCESS : $CHALLENGE : Deployed successfully" -else - echo -e "$ERROR : $CHALLENGE : There was an error in deployment of challenge" - exit 1 -fi +cleanup() { + if [[ "${deployed}" == true ]]; then + "${beast_bin}" challenge purge "${challenge_name}" --delete-entry || true + fi +} +trap cleanup EXIT INT TERM -# Test challenge xinetd-service -CHALLENGE="xinetd-service" -PORT=10003 -## Check if port is taken -checkPortAvailable $CHALLENGE $PORT -## Deploy challenge -deployChallenge $CHALLENGE $PWD/_examples/$CHALLENGE -## Test deployment -echo -e "$INFO : $CHALLENGE : Test deployment" -checkPortReachable $PORT -if [[ $? -eq 0 ]]; then - echo -e "$SUCCESS : $CHALLENGE : Deployed successfully" -else - echo -e "$ERROR : $CHALLENGE : There was an error in deployment of challenge" - exit 1 +if timeout 1 bash -c "/dev/null; then + echo "port ${challenge_port} is already in use" >&2 + exit 1 fi -# Test challenge xinetd-service -CHALLENGE="docker-type" -PORT=10005 -## Check if port is taken -checkPortAvailable $CHALLENGE $PORT -## Deploy challenge -deployChallenge $CHALLENGE $PWD/_examples/$CHALLENGE -## Test deployment -echo -e "$INFO : $CHALLENGE : Test deployment" -checkPortReachable $PORT -if [[ $? -eq 0 ]]; then - echo -e "$SUCCESS : $CHALLENGE : Deployed successfully" -else - echo -e "$ERROR : $CHALLENGE : There was an error in deployment of challenge" - exit 1 -fi +"${beast_bin}" challenge deploy --local-directory "${challenge_dir}" +deployed=true + +for _ in {1..30}; do + if timeout 1 bash -c "/dev/null; then + echo "challenge ${challenge_name} is reachable on port ${challenge_port}" + exit 0 + fi + sleep 1 +done -## Purge all challenges -# simple -purge simple -# static-chall -purge static-chall -# web-php -purge web-php -# web-php-mysql -purge web-php-mysql -# xinetd-service -purge xinetd-service +echo "challenge ${challenge_name} did not become reachable" >&2 +exit 1 From 06f89a6e37cc4b1e0df09c6ce57539f5a5b86ace Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 14:57:37 +0530 Subject: [PATCH 113/149] build: provision verified development tools Signed-off-by: vibhatsu --- Vagrantfile | 11 +++++------ scripts/provision/dependencies.sh | 21 +++++++++++++++++++++ 2 files changed, 26 insertions(+), 6 deletions(-) create mode 100755 scripts/provision/dependencies.sh diff --git a/Vagrantfile b/Vagrantfile index 39a8515d..7032fc6c 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -33,8 +33,7 @@ Vagrant.configure(VAGRANTFILE_VERSION) do |config| config.vm.hostname = "beast" - # The default box for the machine is ubuntu/bionic64 - config.vm.box = "ubuntu/bionic64" + config.vm.box = "ubuntu/noble64" # The Beast environment runs on 9991 on the guest. host_port = 5005 @@ -54,25 +53,25 @@ Vagrant.configure(VAGRANTFILE_VERSION) do |config| # Provider-specific configuration so you can fine-tune various # backing providers for Vagrant. These expose provider-specific options. config.vm.provider "virtualbox" do |vb, override| - override.vm.box = "ubuntu/bionic64" + override.vm.box = "ubuntu/noble64" # Customize the amount of memory on the VM: vb.memory = vm_memory end config.vm.provider "hyperv" do |h, override| - override.vm.box = "bento/ubuntu-18.04" + override.vm.box = "bento/ubuntu-24.04" h.memory = vm_memory h.maxmemory = vm_memory h.cpus = vm_num_cpus end config.vm.provider "parallels" do |prl, override| - override.vm.box = "bento/ubuntu-18.04" - override.vm.box_version = "202005.21.0" + override.vm.box = "bento/ubuntu-24.04" prl.memory = vm_memory prl.cpus = vm_num_cpus end + config.vm.provision "dependencies", type: "shell", path: "scripts/provision/dependencies.sh", privileged: true config.vm.provision "docker" config.vm.provision "env", type: "shell", path: "scripts/installenv.sh", privileged: false config.vm.provision "setup", type: "shell", after: "env", path: "scripts/provision/setup.sh", privileged: false diff --git a/scripts/provision/dependencies.sh b/scripts/provision/dependencies.sh new file mode 100755 index 00000000..f41e18e3 --- /dev/null +++ b/scripts/provision/dependencies.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash + +set -euo pipefail + +readonly go_version="1.23.12" +readonly go_sha256="d3847fef834e9db11bf64e3fb34db9c04db14e068eeb064f49af747010454f90" + +export DEBIAN_FRONTEND=noninteractive +apt-get update +apt-get install --yes --no-install-recommends ca-certificates curl git make + +archive=$(mktemp) +trap 'rm -f "${archive}"' EXIT +curl --fail --location --proto '=https' --tlsv1.2 \ + "https://dl.google.com/go/go${go_version}.linux-amd64.tar.gz" \ + --output "${archive}" +printf '%s %s\n' "${go_sha256}" "${archive}" | sha256sum --check --status + +rm -rf /usr/local/go +tar --extract --gzip --file "${archive}" --directory /usr/local +ln -sfn /usr/local/go/bin/go /usr/local/bin/go From bc27b22879255cc445960faf6ccccfa9f9239fa6 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 14:57:38 +0530 Subject: [PATCH 114/149] build(docs): modernize container runtime Signed-off-by: vibhatsu --- Dockerfile | 10 +++++++--- requirements.txt | 6 +++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/Dockerfile b/Dockerfile index 0aa3db65..fa7674ed 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,18 @@ -# Use Python 2.7 as the base image -FROM python:2.7 +FROM python:3.12-alpine WORKDIR /app COPY requirements.txt /app/ -RUN pip install --no-cache-dir -r requirements.txt +RUN pip install --no-cache-dir --requirement requirements.txt + +RUN addgroup -S docs && adduser -S -G docs docs COPY ./docs/ /app/docs/ COPY mkdocs.yml /app/ +RUN chown -R docs:docs /app EXPOSE 8000 +USER docs + CMD ["mkdocs", "serve", "-a", "0.0.0.0:8000"] diff --git a/requirements.txt b/requirements.txt index 085dd2ea..9617821c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ -mkdocs -mkdocs-bootswatch -PyYAML +mkdocs==1.6.1 +mkdocs-bootswatch==1.1 +PyYAML==6.0.2 From bf4e3b3b44fb6007fea9b668761f4c18882a6606 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 14:57:38 +0530 Subject: [PATCH 115/149] fix(setup): generate datastore credentials Signed-off-by: vibhatsu --- setup.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/setup.sh b/setup.sh index cd881807..35e47d2d 100755 --- a/setup.sh +++ b/setup.sh @@ -25,7 +25,11 @@ if [[ ! -e "${config_file}" ]]; then cp "${repo_dir}/_examples/example.config.toml" "${config_file}" chmod 0600 "${config_file}" jwt_secret=$(od -An -N32 -tx1 /dev/urandom | tr -d ' \n') + postgres_password=$(od -An -N24 -tx1 /dev/urandom | tr -d ' \n') + redis_password=$(od -An -N24 -tx1 /dev/urandom | tr -d ' \n') sed -i "s/CHANGE_ME_GENERATED_BY_SETUP/${jwt_secret}/" "${config_file}" + sed -i "0,/password = \"CHANGE_ME\"/s//password = \"${postgres_password}\"/" "${config_file}" + sed -i "0,/password = \"CHANGE_ME\"/s//password = \"${redis_password}\"/" "${config_file}" created_config=true elif [[ -L "${config_file}" || $(stat -c '%a' "${config_file}") != 600 ]]; then echo "existing config must be a regular file with mode 0600: ${config_file}" >&2 @@ -35,7 +39,7 @@ fi BEAST_OUTPUT=${BEAST_OUTPUT:-"$(go env GOPATH)/bin/beast"} make -C "${repo_dir}" build if [[ "${created_config}" == true ]]; then - echo "Created ${config_file} with a unique JWT secret." + echo "Created ${config_file} with unique JWT and datastore credentials." echo "Before starting Beast, configure PostgreSQL, Redis, SMTP, and TLS certificate paths." else echo "Using existing ${config_file}." From 2067eb3375070bbc284f35af51652a517b018776 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 14:59:18 +0530 Subject: [PATCH 116/149] fix(postgres): require verified remote TLS Signed-off-by: vibhatsu --- _examples/example.config.toml | 1 + cmd/beast/config.go | 3 ++ cmd/beast/init.go | 52 +++++++++++++++++++++++----------- core/config/config.go | 36 +++++++++++++++++++---- core/config/utils_test.go | 19 +++++++++++++ core/database/database.go | 17 +++++++++-- core/database/database_test.go | 16 +++++++---- 7 files changed, 112 insertions(+), 32 deletions(-) diff --git a/_examples/example.config.toml b/_examples/example.config.toml index 97398fc8..d26d65e9 100644 --- a/_examples/example.config.toml +++ b/_examples/example.config.toml @@ -109,6 +109,7 @@ dbname = "beast" host = "localhost" port = "5432" sslmode = "prefer" +sslrootcert = "" [redis_config] host = "localhost" diff --git a/cmd/beast/config.go b/cmd/beast/config.go index b7d75a14..05f97324 100644 --- a/cmd/beast/config.go +++ b/cmd/beast/config.go @@ -188,6 +188,9 @@ func promptDatabaseConnectionDetails(configuration *config.BeastConfig) error { "verify-ca", "verify-full", }) + if configuration.PsqlConf.SslMode == "verify-ca" || configuration.PsqlConf.SslMode == "verify-full" { + configuration.PsqlConf.SSLRootCert = utils.PromptString("Postgres root CA certificate path") + } return nil } diff --git a/cmd/beast/init.go b/cmd/beast/init.go index ab167649..e729938d 100644 --- a/cmd/beast/init.go +++ b/cmd/beast/init.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "net" + "net/url" "os" "os/user" "path/filepath" @@ -164,8 +165,33 @@ func dbUserCheck() (bool, error) { return current.Username == core.POSTGRES_SUPER_USER, nil } +func postgresAdminDSN(configuration config.PsqlConfig, password string) string { + dsn := &url.URL{ + Scheme: "postgresql", + User: url.UserPassword(core.POSTGRES_SUPER_USER, password), + Host: net.JoinHostPort(configuration.Host, configuration.Port), + Path: "postgres", + } + query := dsn.Query() + query.Set("sslmode", configuration.SslMode) + if configuration.SSLRootCert != "" { + query.Set("sslrootcert", configuration.SSLRootCert) + } + dsn.RawQuery = query.Encode() + return dsn.String() +} + +func isLoopbackHost(host string) bool { + if host == "localhost" { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + func initDb() error { log.Infoln("Initializing database...") + configuration := config.Cfg.PsqlConf isPostgres, err := dbUserCheck() if err != nil { @@ -173,37 +199,29 @@ func initDb() error { } var db *sql.DB - if isPostgres { + if isPostgres && isLoopbackHost(configuration.Host) { log.Infoln("Attempting to connect to postgres as postgres super user...") - dsn := fmt.Sprintf("user=%s dbname=%s sslmode=%s", "postgres", "postgres", "disable") - db, err = sql.Open("pgx", dsn) + db, err = sql.Open("pgx", "user=postgres dbname=postgres sslmode=disable") if err != nil { return err } } else { - log.Warnln("Current user is not postgres super user...") - - if utils.PromptBinary("Do you use password authentication for the postgres super user?") { - password := utils.PromptSecret("Enter postgres super user password (leave blank if none):") - - dsn := fmt.Sprintf("user=%s password=%s dbname=%s sslmode=%s", "postgres", password, "postgres", "disable") - db, err = sql.Open("pgx", dsn) - - if err != nil { - return err - } - } else { + log.Warnln("A PostgreSQL superuser password is required for this connection...") + if !utils.PromptBinary("Connect using password authentication for the postgres superuser?") { log.Errorln("Cannot continue with postgres setup... Please run this command as the postgres super user (preferred) or use password authentication.") return errors.New("failed to initialize database") } + password := utils.PromptSecret("Enter postgres superuser password:") + db, err = sql.Open("pgx", postgresAdminDSN(configuration, password)) + if err != nil { + return err + } } defer db.Close() - configuration := config.Cfg.PsqlConf - var exists int err = db.QueryRow("SELECT 1 FROM pg_roles WHERE rolname = $1", configuration.User).Scan(&exists) if errors.Is(err, sql.ErrNoRows) { diff --git a/core/config/config.go b/core/config/config.go index 25bcc303..fe3bced2 100644 --- a/core/config/config.go +++ b/core/config/config.go @@ -107,6 +107,7 @@ var gitBranchPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$`) // host = "localhost" // port = "5432" // sslmode = "prefer" +// sslrootcert = "" // ``` type BeastConfig struct { AllowedBaseImages []string `toml:"allowed_base_images"` @@ -469,12 +470,13 @@ func (config *GitRemote) ValidateGitConfig() error { } type PsqlConfig struct { - User string `toml:"user"` - Password string `toml:"password"` - Dbname string `toml:"dbname"` - Host string `toml:"host"` - Port string `toml:"port"` - SslMode string `toml:"sslmode"` + User string `toml:"user"` + Password string `toml:"password"` + Dbname string `toml:"dbname"` + Host string `toml:"host"` + Port string `toml:"port"` + SslMode string `toml:"sslmode"` + SSLRootCert string `toml:"sslrootcert"` } type RedisConfig struct { @@ -507,6 +509,28 @@ func (config *PsqlConfig) ValidatePsqlConfig() error { if _, ok := validSSLModes[config.SslMode]; !ok { return fmt.Errorf("unsupported psql sslmode %q", config.SslMode) } + isLoopback := config.Host == "localhost" + if ip := net.ParseIP(config.Host); ip != nil { + isLoopback = ip.IsLoopback() + } + if !isLoopback && config.SslMode != "verify-full" { + return errors.New("psql sslmode verify-full is required for non-loopback connections") + } + if config.SslMode == "verify-ca" || config.SslMode == "verify-full" { + if config.SSLRootCert == "" { + return errors.New("psql sslrootcert is required when verifying TLS") + } + rootCert, err := utils.ExpandHomePath(config.SSLRootCert) + if err != nil { + return fmt.Errorf("expand psql root certificate: %w", err) + } + config.SSLRootCert = rootCert + if err := utils.ValidateFileExists(config.SSLRootCert); err != nil { + return fmt.Errorf("validate psql root certificate: %w", err) + } + } else if config.SSLRootCert != "" { + return errors.New("psql sslrootcert requires sslmode verify-ca or verify-full") + } return nil } diff --git a/core/config/utils_test.go b/core/config/utils_test.go index a46c4fb3..63a8e338 100644 --- a/core/config/utils_test.go +++ b/core/config/utils_test.go @@ -363,6 +363,25 @@ func TestRemoteRedisRequiresTLS(t *testing.T) { } } +func TestRemotePostgresRequiresVerifiedTLS(t *testing.T) { + psql := PsqlConfig{User: "beast", Password: "secret", Dbname: "beast", Host: "db.example.com", Port: "5432", SslMode: "require"} + if err := psql.ValidatePsqlConfig(); err == nil || !strings.Contains(err.Error(), "verify-full") { + t.Fatalf("expected remote TLS verification rejection, got %v", err) + } + psql.SslMode = "verify-full" + if err := psql.ValidatePsqlConfig(); err == nil || !strings.Contains(err.Error(), "sslrootcert") { + t.Fatalf("expected missing root certificate rejection, got %v", err) + } + rootCert := filepath.Join(t.TempDir(), "root.pem") + if err := os.WriteFile(rootCert, []byte("test"), 0600); err != nil { + t.Fatal(err) + } + psql.SSLRootCert = rootCert + if err := psql.ValidatePsqlConfig(); err != nil { + t.Fatalf("expected verified remote PostgreSQL config to pass: %v", err) + } +} + func TestCompetitionInfoValidatesTimeWindow(t *testing.T) { info := CompetitionInfo{ StartingTime: "00:00:00 UTC: +05:30, 1 January 2030, Tuesday", diff --git a/core/database/database.go b/core/database/database.go index 24f3411a..183c4d41 100644 --- a/core/database/database.go +++ b/core/database/database.go @@ -49,10 +49,21 @@ func postgresDSN(config beastConfig.PsqlConfig) string { } query := dsn.Query() query.Set("sslmode", config.SslMode) + if config.SSLRootCert != "" { + query.Set("sslrootcert", config.SSLRootCert) + } dsn.RawQuery = query.Encode() return dsn.String() } +func postgresEnvironment(config beastConfig.PsqlConfig) []string { + environment := append(os.Environ(), "PGPASSWORD="+config.Password, "PGSSLMODE="+config.SslMode) + if config.SSLRootCert != "" { + environment = append(environment, "PGSSLROOTCERT="+config.SSLRootCert) + } + return environment +} + // Connect psql database func ConnectDatabase() error { if err := LoadDbConfig(); err != nil { @@ -167,7 +178,7 @@ func BackupDatabase() error { } backupFile := fmt.Sprintf("%s_%s.bak", dbConfig.Dbname, time.Now().Format("20060102150405")) - environment := append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", dbConfig.Password), "PGSSLMODE="+dbConfig.SslMode) + environment := postgresEnvironment(dbConfig) output, err := utils.RunCommand(30*time.Minute, environment, "pg_dump", "-U", dbConfig.User, "-h", dbConfig.Host, "-p", dbConfig.Port, "-F", "c", "-f", filepath.Join(backupPath, backupFile), dbConfig.Dbname) if err != nil { return fmt.Errorf("pg_dump failed: %w; output: %s", err, output) @@ -182,7 +193,7 @@ func ResetDatabase() error { return err } } - environment := append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", dbConfig.Password), "PGSSLMODE="+dbConfig.SslMode) + environment := postgresEnvironment(dbConfig) output, err := utils.RunCommand(2*time.Minute, environment, "dropdb", "-U", dbConfig.User, "-h", dbConfig.Host, "-p", dbConfig.Port, "--force", dbConfig.Dbname) if err != nil { return fmt.Errorf("drop database: %w; output: %s", err, output) @@ -206,7 +217,7 @@ func RestoreDatabase(backupFile string) error { return fmt.Errorf("backup file does not exist: %s", backupFile) } - environment := append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", dbConfig.Password), "PGSSLMODE="+dbConfig.SslMode) + environment := postgresEnvironment(dbConfig) output, err := utils.RunCommand(30*time.Minute, environment, "pg_restore", "-U", dbConfig.User, "-h", dbConfig.Host, diff --git a/core/database/database_test.go b/core/database/database_test.go index 5513f45a..4ebb129e 100644 --- a/core/database/database_test.go +++ b/core/database/database_test.go @@ -9,12 +9,13 @@ import ( func TestPostgresDSNEncodesCredentials(t *testing.T) { dsn := postgresDSN(config.PsqlConfig{ - User: "beast user", - Password: "secret with =' delimiters", - Dbname: "beast-db", - Host: "127.0.0.1", - Port: "5432", - SslMode: "require", + User: "beast user", + Password: "secret with =' delimiters", + Dbname: "beast-db", + Host: "127.0.0.1", + Port: "5432", + SslMode: "require", + SSLRootCert: "/etc/ssl/certs/root.pem", }) parsed, err := url.Parse(dsn) if err != nil { @@ -27,4 +28,7 @@ func TestPostgresDSNEncodesCredentials(t *testing.T) { if parsed.Query().Get("sslmode") != "require" { t.Fatalf("sslmode did not round trip through DSN: %s", dsn) } + if parsed.Query().Get("sslrootcert") != "/etc/ssl/certs/root.pem" { + t.Fatalf("sslrootcert did not round trip through DSN: %s", dsn) + } } From 74b06cdea6e4c5ff22c4e189becfe3b4e288c69c Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 15:01:35 +0530 Subject: [PATCH 117/149] fix(redis): constrain service account commands Signed-off-by: vibhatsu --- cmd/beast/init.go | 18 +++++++++++++++++- cmd/beast/init_test.go | 19 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 cmd/beast/init_test.go diff --git a/cmd/beast/init.go b/cmd/beast/init.go index e729938d..e3a28493 100644 --- a/cmd/beast/init.go +++ b/cmd/beast/init.go @@ -67,6 +67,21 @@ func checkDockerDaemon() error { return nil } +func beastRedisACLRules(password string) []string { + return []string{ + "reset", "on", ">" + password, + "~beast:*", "&__keyevent@*__:expired", + "+ping", "+select", "+client|setinfo", + "+get", "+set", "+del", "+expire", "+ttl", "+scan", + "+sadd", "+srem", "+smembers", "+sismember", + "+lpush", "+rpop", "+llen", + "+eval", "+multi", "+exec", "+discard", "+watch", "+unwatch", + "+psubscribe", "+punsubscribe", + "+config|get", "+config|set", + "+flushdb", "+psync", "+replconf", + } +} + func createBeastRedisUser(cache *redis.Client, configuration *config.RedisConfig) error { ctx := context.Background() log.Warnln("Beast expects Redis ACLs to be enabled. If ACLs are not configured, some features may not function correctly.") @@ -83,7 +98,8 @@ func createBeastRedisUser(cache *redis.Client, configuration *config.RedisConfig } } - _, err = cache.ACLSetUser(ctx, configuration.User, "on", ">"+configuration.Password, "~beast:*", "+@all").Result() + rules := beastRedisACLRules(configuration.Password) + _, err = cache.ACLSetUser(ctx, configuration.User, rules...).Result() if err != nil { return err } diff --git a/cmd/beast/init_test.go b/cmd/beast/init_test.go new file mode 100644 index 00000000..fbed0df2 --- /dev/null +++ b/cmd/beast/init_test.go @@ -0,0 +1,19 @@ +package main + +import ( + "slices" + "testing" +) + +func TestBeastRedisACLRulesUseExplicitCommands(t *testing.T) { + rules := beastRedisACLRules("secret") + if !slices.Contains(rules, "reset") || !slices.Contains(rules, "~beast:*") { + t.Fatalf("ACL rules do not reset and constrain key access: %v", rules) + } + if slices.Contains(rules, "+@all") { + t.Fatalf("ACL rules grant every Redis command: %v", rules) + } + if !slices.Contains(rules, "+eval") || !slices.Contains(rules, "+config|get") { + t.Fatalf("ACL rules omit required Beast commands: %v", rules) + } +} From 5075de43fc1a9beb8a08b65104f53a8ced835d93 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 15:05:36 +0530 Subject: [PATCH 118/149] fix(cli): initialize management runtime Signed-off-by: vibhatsu --- cmd/beast/challdetails.go | 11 ++- cmd/beast/challenge.go | 137 ++++++++++++++---------------------- cmd/beast/challenge_logs.go | 18 +++-- cmd/beast/healthprobe.go | 15 ++-- cmd/beast/runtime.go | 63 +++++++++++++++++ cmd/beast/verify.go | 21 +++--- core/manager/challenge.go | 4 +- core/manager/pipeline.go | 8 ++- core/utils/logs.go | 22 +++--- 9 files changed, 170 insertions(+), 129 deletions(-) create mode 100644 cmd/beast/runtime.go diff --git a/cmd/beast/challdetails.go b/cmd/beast/challdetails.go index 2da2fbf5..63bead47 100644 --- a/cmd/beast/challdetails.go +++ b/cmd/beast/challdetails.go @@ -9,8 +9,13 @@ var challDetailsCmd = &cobra.Command{ Use: "chall-details", Short: "Lists all challenge details", Long: "Lists all challenge details | Flags available : --status , --tags. Status flag can take arguments : deployed / undeployed / queued. Tags flag can take multiple arguments seperated with ',' : (Ex : --tags=pwn,image,docker). Details are shown for challenges that have specified status and one of the specified tags.", - - Run: func(cmd *cobra.Command, args []string) { - utils.ShowFilteredChallengesInfo(cmd, args) + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + cleanup, err := initializeCLIRuntime(false, false) + if err != nil { + return err + } + defer cleanup() + return utils.ShowFilteredChallengesInfo(cmd, args) }, } diff --git a/cmd/beast/challenge.go b/cmd/beast/challenge.go index c16da7a3..f03d0219 100644 --- a/cmd/beast/challenge.go +++ b/cmd/beast/challenge.go @@ -1,11 +1,10 @@ package main import ( - "os" + "fmt" "strings" "github.com/sdslabs/beastv4/core" - "github.com/sdslabs/beastv4/core/config" "github.com/sdslabs/beastv4/core/manager" "github.com/sdslabs/beastv4/core/utils" wpool "github.com/sdslabs/beastv4/pkg/workerpool" @@ -18,114 +17,84 @@ var challengeCmd = &cobra.Command{ Short: "Performs action to the challs", Long: "Performs actions like : deploy, undeploy, redeploy, purge to the challs", Args: cobra.MinimumNArgs(1), - Run: func(cmd *cobra.Command, args []string) { - if err := config.InitConfig(); err != nil { - log.Error(err) - return - } - - // Since action is already verfied to exist it does not make sense to check - // its existence here therefore we directly parse the action from the command. + RunE: func(cmd *cobra.Command, args []string) error { action := args[0] - noCache, _ := cmd.Flags().GetBool("no-cache") - if noCache && action == core.MANAGE_ACTION_DEPLOY { - config.NoCache = noCache - } else if noCache { - log.Errorf("no-cache flag is available only for \"deploy\" action") - os.Exit(1) + if NoCache && action != core.MANAGE_ACTION_DEPLOY { + return fmt.Errorf("no-cache flag is available only for deploy") } if action == core.MANAGE_ACTION_SHOW { - - if AllChalls { - errors := utils.ShowAllChallenges() - - if len(errors) > 0 { - for _, err := range errors { - log.Errorf("The following errors occurred: %s", err.Error()) - } - os.Exit(1) - } - - } else if Tag != "" { - errors := utils.ShowTagRelatedChallenges(Tag) - - if len(errors) > 0 { - for _, err := range errors { - log.Errorf("The following errors occurred: %s", err.Error()) - } - os.Exit(1) - } - } else { - if len(args) == 1 { - log.Errorf("Provide chall name") - os.Exit(1) - } - - errors := utils.ShowChallengeByName(args[1]) - if len(errors) > 0 { - for _, err := range errors { - log.Errorf("The following errors occurred: %s", err.Error()) - } - os.Exit(1) - } - + cleanup, err := initializeCLIRuntime(false, false) + if err != nil { + return err } - - return + defer cleanup() + return showChallenges(args) } challAction, ok := manager.ChallengeActionHandlers[action] if !ok { - log.Errorf("No action %s exists", action) - os.Exit(1) + return fmt.Errorf("no challenge action %q exists", action) + } + cleanup, err := initializeCLIRuntime(true, true) + if err != nil { + return err } + defer cleanup() - // Handle local directory deployment separately. if LocalDirectory != "" { if action != core.MANAGE_ACTION_DEPLOY { - log.Errorf("Only deploy action is available for the challenge with local directory") - os.Exit(1) + return fmt.Errorf("local-directory is available only for deploy") } - - manager.StartDeployPipeline(LocalDirectory, false, false, false) - return + return manager.StartDeployPipeline(LocalDirectory, false, false, NoCache) } - completionChannel := make(chan bool) - - manager.Q = wpool.InitQueue(core.MAX_QUEUE_SIZE, completionChannel) + completion := make(chan bool, 1) + manager.Q = wpool.InitQueue(core.MAX_QUEUE_SIZE, completion) manager.Q.StartWorkers(&manager.Worker{}) + defer manager.Q.Stop() if AllChalls { - errstrings := manager.HandleAll(action, core.BEAST_LOCAL_SERVER) - if len(errstrings) != 0 { - log.Errorf("Following errors occurred : %s", strings.Join(errstrings, " || ")) - os.Exit(1) - } else { - log.Info("The action will be performed") + if failures := manager.HandleAll(action, core.BEAST_LOCAL_SERVER); len(failures) != 0 { + return fmt.Errorf("challenge actions failed: %s", strings.Join(failures, " || ")) } } else if Tag != "" { - errstrings := manager.HandleTagRelatedChallenges(action, Tag, core.BEAST_LOCAL_SERVER) - if len(errstrings) != 0 { - log.Errorf("Following errors occurred : %s", strings.Join(errstrings, " || ")) - os.Exit(1) - } else { - log.Info("The action will be performed") + if failures := manager.HandleTagRelatedChallenges(action, Tag, core.BEAST_LOCAL_SERVER); len(failures) != 0 { + return fmt.Errorf("challenge actions failed: %s", strings.Join(failures, " || ")) } } else { if len(args) == 1 { - log.Errorf("Provide chall name") - os.Exit(1) + return fmt.Errorf("challenge name is required") } - err := challAction(args[1]) - if err != nil { - log.Errorf("The action was not performed due to error : %s", err.Error()) - os.Exit(1) - } else { - log.Info("The action will be performed") + if err := challAction(args[1]); err != nil { + return fmt.Errorf("perform %s on %s: %w", action, args[1], err) } } - _ = <-completionChannel + + <-completion + log.Info("Challenge action completed") + return nil }, } + +func showChallenges(args []string) error { + var failures []error + switch { + case AllChalls: + failures = utils.ShowAllChallenges() + case Tag != "": + failures = utils.ShowTagRelatedChallenges(Tag) + case len(args) < 2: + return fmt.Errorf("challenge name is required") + default: + failures = utils.ShowChallengeByName(args[1]) + } + if len(failures) == 0 { + return nil + } + messages := make([]string, 0, len(failures)) + for _, err := range failures { + messages = append(messages, err.Error()) + } + return fmt.Errorf("show challenges: %s", strings.Join(messages, "; ")) +} diff --git a/cmd/beast/challenge_logs.go b/cmd/beast/challenge_logs.go index 0d2f58f3..d2f28a31 100644 --- a/cmd/beast/challenge_logs.go +++ b/cmd/beast/challenge_logs.go @@ -1,23 +1,21 @@ package main import ( - "github.com/sdslabs/beastv4/core/config" "github.com/sdslabs/beastv4/core/utils" - log "github.com/sirupsen/logrus" "github.com/spf13/cobra" ) var logsCmd = &cobra.Command{ Use: "logs CHALLNAME", Short: "Provides live logs of a container", - Args: cobra.MinimumNArgs(1), - - Run: func(cmd *cobra.Command, args []string) { - if err := config.InitConfig(); err != nil { - log.Error(err) - return + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cleanup, err := initializeCLIRuntime(false, false) + if err != nil { + return err } - - utils.GetLogs(args[0], true) + defer cleanup() + _, err = utils.GetLogs(args[0], true) + return err }, } diff --git a/cmd/beast/healthprobe.go b/cmd/beast/healthprobe.go index 4926d7f1..a20d2acd 100644 --- a/cmd/beast/healthprobe.go +++ b/cmd/beast/healthprobe.go @@ -9,7 +9,6 @@ import ( "github.com/sdslabs/beastv4/core" "github.com/sdslabs/beastv4/core/config" "github.com/sdslabs/beastv4/core/manager" - log "github.com/sirupsen/logrus" "github.com/spf13/cobra" ) @@ -18,20 +17,22 @@ var healthProbeCmd = &cobra.Command{ Short: "Run Health Probe", Long: "Run Health Probe only without API server", - Run: func(cmd *cobra.Command, args []string) { - if err := config.InitConfig(); err != nil { - log.Error(err) - return + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + cleanup, err := initializeCLIRuntime(true, true) + if err != nil { + return err } + defer cleanup() controllerLock, err := acquireControllerLock(core.BEAST_GLOBAL_DIR) if err != nil { - log.Error(err) - return + return err } defer releaseControllerLock(controllerLock) ctx, stopSignals := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stopSignals() manager.BeastHealthCheckProber(ctx, config.Cfg.TickerFrequency) + return nil }, } diff --git a/cmd/beast/runtime.go b/cmd/beast/runtime.go new file mode 100644 index 00000000..3adccad7 --- /dev/null +++ b/cmd/beast/runtime.go @@ -0,0 +1,63 @@ +package main + +import ( + "github.com/sdslabs/beastv4/core" + "github.com/sdslabs/beastv4/core/cache" + "github.com/sdslabs/beastv4/core/config" + "github.com/sdslabs/beastv4/core/database" + "github.com/sdslabs/beastv4/pkg/auth" + "github.com/sdslabs/beastv4/pkg/remoteManager" + log "github.com/sirupsen/logrus" +) + +func initializeCLIRuntime(requireCache, requireRemotes bool) (func(), error) { + if err := config.InitConfig(); err != nil { + return nil, err + } + auth.Init(core.ITERATIONS, core.HASH_LENGTH, core.TIMEPERIOD, core.ISSUER, config.Cfg.JWTSecret, []string{core.USER_ROLES["author"], core.USER_ROLES["maintainer"]}, []string{core.USER_ROLES["admin"]}, []string{core.USER_ROLES["contestant"]}) + if err := database.Init(); err != nil { + closeCLIDatabase() + return nil, err + } + + cacheInitialized := false + if requireCache { + redis := config.Cfg.RedisConf + cache.Configure(redis.User, redis.Password, redis.Host, redis.Port, redis.Db, redis.TLS, redis.CAFile, redis.ServerName) + if err := cache.Init(); err != nil { + closeCLIDatabase() + return nil, err + } + cacheInitialized = true + } + if requireRemotes { + remoteManager.Init() + } + + return func() { + if requireRemotes { + remoteManager.Stop() + } + if cacheInitialized { + if err := cache.Close(); err != nil { + log.Warnf("close CLI cache: %v", err) + } + } + closeCLIDatabase() + }, nil +} + +func closeCLIDatabase() { + if database.Db == nil { + return + } + sqlDB, err := database.Db.DB() + if err != nil { + log.Warnf("access CLI database connection: %v", err) + return + } + if err := sqlDB.Close(); err != nil { + log.Warnf("close CLI database connection: %v", err) + } + database.Db = nil +} diff --git a/cmd/beast/verify.go b/cmd/beast/verify.go index d26cc687..664ca60c 100644 --- a/cmd/beast/verify.go +++ b/cmd/beast/verify.go @@ -1,10 +1,11 @@ package main import ( + "fmt" + "github.com/sdslabs/beastv4/core/config" "github.com/sdslabs/beastv4/core/manager" coreUtils "github.com/sdslabs/beastv4/core/utils" - log "github.com/sirupsen/logrus" "github.com/spf13/cobra" ) @@ -12,26 +13,22 @@ import ( var verifyCmd = &cobra.Command{ Use: "verify challenge-name", Short: "Verifies challenge config", - Args: cobra.MinimumNArgs(1), + Args: cobra.ExactArgs(1), - Run: func(cmd *cobra.Command, args []string) { + RunE: func(cmd *cobra.Command, args []string) error { if err := config.InitConfig(); err != nil { - log.Error(err) - return + return err } challengeName := args[0] challengeDir := coreUtils.GetChallengeDir(challengeName) if challengeDir == "" { - log.Errorf("Challenge does not exist") - return + return fmt.Errorf("challenge %q does not exist", challengeName) } - err := manager.ValidateChallengeConfig(challengeDir) - if err != nil { - log.Warnf("Error while validating challenge %s : %s", challengeName, err.Error()) - } else { - log.Infof("The challenge config is verified.") + if err := manager.ValidateChallengeConfig(challengeDir); err != nil { + return fmt.Errorf("validate challenge %s: %w", challengeName, err) } + return nil }, } diff --git a/core/manager/challenge.go b/core/manager/challenge.go index c362e181..0f61af63 100644 --- a/core/manager/challenge.go +++ b/core/manager/challenge.go @@ -81,7 +81,9 @@ func (worker *Worker) PerformTask(w wpool.Task) *wpool.Task { info := w.Info.(TaskInfo) switch info.Action { case core.MANAGE_ACTION_DEPLOY: - StartDeployPipeline(info.ChallDir, info.SkipStage, info.SkipCommit, info.NoCache) + if err := StartDeployPipeline(info.ChallDir, info.SkipStage, info.SkipCommit, info.NoCache); err != nil { + log.Errorf("Error while deploying challenge(%s): %s", w.ID, err) + } case core.MANAGE_ACTION_UNDEPLOY: err := StartUndeployChallenge(w.ID, false) diff --git a/core/manager/pipeline.go b/core/manager/pipeline.go index f85fbeb0..5a4f782c 100644 --- a/core/manager/pipeline.go +++ b/core/manager/pipeline.go @@ -607,13 +607,16 @@ func bootstrapDeployPipeline(challengeDir string, skipStage bool, skipCommit boo // This is just a decorator function over bootstrapDeployPipeline and generate // notifications to slack on the basis of the result of the deploy pipeline. -func StartDeployPipeline(challengeDir string, skipStage bool, skipCommit bool, noCache bool) { +func StartDeployPipeline(challengeDir string, skipStage bool, skipCommit bool, noCache bool) error { challengeName := filepath.Base(challengeDir) var sendNotificationError error err := bootstrapDeployPipeline(challengeDir, skipStage, skipCommit, noCache) if err != nil { - sendNotificationError = notify.SendNotification(notify.Error, err.Error()) + if notificationErr := notify.SendNotification(notify.Error, err.Error()); notificationErr != nil { + log.Warnf("%s: failure notification could not be sent: %v", challengeName, notificationErr) + } + return err } else { msg := fmt.Sprintf("DEPLOY SUCCESS : %s : Challenge deployment pipeline successful.", challengeName) sendNotificationError = notify.SendNotification(notify.Success, msg) @@ -622,4 +625,5 @@ func StartDeployPipeline(challengeDir string, skipStage bool, skipCommit bool, n if sendNotificationError == nil { log.Debugf("%s: Notification sent", challengeName) } + return nil } diff --git a/core/utils/logs.go b/core/utils/logs.go index d7de21d9..90a701a5 100644 --- a/core/utils/logs.go +++ b/core/utils/logs.go @@ -27,19 +27,21 @@ func GetLogs(challname string, live bool) (*cr.Log, error) { if !IsContainerIdValid(chall.ContainerId) { return nil, fmt.Errorf("Underlying challenge configuration present is not valid.") } - var containers, remoteContainers []container_types.Container - server := config.AvailableServer{} - remoteContainers, err = remoteManager.SearchContainerByFilterRemote(map[string]string{"id": chall.ContainerId}, server) - if err != nil { - return nil, fmt.Errorf("Error while searching for remote container with id %s", chall.ContainerId) + var containers []container_types.Container + if config.Cfg.UseLocalDockerDaemon(chall.ServerDeployed) { + containers, err = cr.SearchContainerByFilter(map[string]string{"id": chall.ContainerId}) + } else { + server, ok := config.Cfg.AvailableServers[chall.ServerDeployed] + if !ok { + return nil, fmt.Errorf("deployment server %q is not configured", chall.ServerDeployed) + } + containers, err = remoteManager.SearchContainerByFilterRemote(map[string]string{"id": chall.ContainerId}, server) } - containers, err = cr.SearchContainerByFilter(map[string]string{"id": chall.ContainerId}) if err != nil { - return nil, fmt.Errorf("Error while searching for container with id %s", chall.ContainerId) + return nil, fmt.Errorf("search for container %s: %w", chall.ContainerId, err) } - containers = append(containers, remoteContainers...) if len(containers) > 1 { - return nil, errors.New("Got more than one containers, something fishy here. Contact admin to check manually.") + return nil, errors.New("multiple containers matched the challenge container ID") } if len(containers) == 0 { @@ -64,7 +66,7 @@ func GetLogs(challname string, live bool) (*cr.Log, error) { return cr.GetContainerStdLogs(chall.ContainerId) } - server = config.Cfg.AvailableServers[chall.ServerDeployed] + server := config.Cfg.AvailableServers[chall.ServerDeployed] return remoteManager.GetContainerStdLogsRemote(chall.ContainerId, server) } From 1a1162f2d696f8ae3298f39ff07221ffc4ccb9f8 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 15:07:41 +0530 Subject: [PATCH 119/149] fix(workers): report queued action failures Signed-off-by: vibhatsu --- cmd/beast/challenge.go | 4 ++++ core/manager/challenge.go | 7 +++++++ pkg/workerpool/pool.go | 16 ++++++++++++++++ pkg/workerpool/pool_test.go | 15 +++++++++++++++ 4 files changed, 42 insertions(+) diff --git a/cmd/beast/challenge.go b/cmd/beast/challenge.go index f03d0219..0af0eee8 100644 --- a/cmd/beast/challenge.go +++ b/cmd/beast/challenge.go @@ -1,6 +1,7 @@ package main import ( + "errors" "fmt" "strings" @@ -72,6 +73,9 @@ var challengeCmd = &cobra.Command{ } <-completion + if failures := manager.Q.Errors(); len(failures) != 0 { + return errors.Join(failures...) + } log.Info("Challenge action completed") return nil }, diff --git a/core/manager/challenge.go b/core/manager/challenge.go index 0f61af63..6bb2f4aa 100644 --- a/core/manager/challenge.go +++ b/core/manager/challenge.go @@ -83,23 +83,27 @@ func (worker *Worker) PerformTask(w wpool.Task) *wpool.Task { case core.MANAGE_ACTION_DEPLOY: if err := StartDeployPipeline(info.ChallDir, info.SkipStage, info.SkipCommit, info.NoCache); err != nil { log.Errorf("Error while deploying challenge(%s): %s", w.ID, err) + Q.RecordError(fmt.Errorf("deploy %s: %w", w.ID, err)) } case core.MANAGE_ACTION_UNDEPLOY: err := StartUndeployChallenge(w.ID, false) if err != nil { log.Errorf("Error while undeplying challenge(%s): %s", w.ID, err.Error()) + Q.RecordError(fmt.Errorf("undeploy %s: %w", w.ID, err)) } case core.MANAGE_ACTION_REDEPLOY: err := StartUndeployChallenge(w.ID, true) if err != nil { log.Errorf("Error while redeplying challenge(%s): %s", w.ID, err.Error()) + Q.RecordError(fmt.Errorf("redeploy %s: %w", w.ID, err)) return nil } work, err := GetDeployWork(w.ID) if err != nil { log.Error(err) + Q.RecordError(fmt.Errorf("prepare redeploy %s: %w", w.ID, err)) return nil } return work @@ -108,17 +112,20 @@ func (worker *Worker) PerformTask(w wpool.Task) *wpool.Task { err := StartUndeployChallenge(w.ID, true) if err != nil { log.Errorf("Error while purging challenge(%s): %s", w.ID, err.Error()) + Q.RecordError(fmt.Errorf("purge %s: %w", w.ID, err)) } default: chall, err := database.QueryFirstChallengeEntry("name", w.ID) if err != nil { log.Errorf("DB_ACCESS_ERROR : %s", err.Error()) + Q.RecordError(fmt.Errorf("query challenge %s: %w", w.ID, err)) } if chall.Name != "" { database.UpdateChallenge(&chall, map[string]interface{}{"status": core.DEPLOY_STATUS["undeployed"]}) log.Errorf("The action(%s) specified for challenge : %s does not exist", info.Action, w.ID) + Q.RecordError(fmt.Errorf("action %s does not exist", info.Action)) } } diff --git a/pkg/workerpool/pool.go b/pkg/workerpool/pool.go index 5a73e770..016a9be8 100644 --- a/pkg/workerpool/pool.go +++ b/pkg/workerpool/pool.go @@ -18,6 +18,22 @@ type Queue struct { stopOnce sync.Once workers sync.WaitGroup stopped bool + errors []error +} + +func (q *Queue) RecordError(err error) { + if err == nil { + return + } + q.Mux.Lock() + defer q.Mux.Unlock() + q.errors = append(q.errors, err) +} + +func (q *Queue) Errors() []error { + q.Mux.RLock() + defer q.Mux.RUnlock() + return append([]error(nil), q.errors...) } type Task struct { diff --git a/pkg/workerpool/pool_test.go b/pkg/workerpool/pool_test.go index 447362aa..4f34a4e3 100644 --- a/pkg/workerpool/pool_test.go +++ b/pkg/workerpool/pool_test.go @@ -1,6 +1,7 @@ package Taskerpool import ( + "fmt" "sync/atomic" "testing" "time" @@ -49,3 +50,17 @@ func TestStopTerminatesWorkersAndRejectsTasks(t *testing.T) { t.Fatal("expected stopped queue error") } } + +func TestQueueRecordsErrorsSafely(t *testing.T) { + queue := InitQueue(1, nil) + want := fmt.Errorf("task failed") + queue.RecordError(want) + errors := queue.Errors() + if len(errors) != 1 || errors[0] != want { + t.Fatalf("Errors() = %v, want [%v]", errors, want) + } + errors[0] = nil + if queue.Errors()[0] != want { + t.Fatal("Errors returned internal queue storage") + } +} From f57a01937e94651574d3756a585da67125df107d Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 15:09:32 +0530 Subject: [PATCH 120/149] fix(probes): verify TLS and bound responses Signed-off-by: vibhatsu --- pkg/probes/http.go | 26 ++++++++++++++++++------ pkg/probes/http_test.go | 44 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 6 deletions(-) create mode 100644 pkg/probes/http_test.go diff --git a/pkg/probes/http.go b/pkg/probes/http.go index a35e08cf..3e37caef 100644 --- a/pkg/probes/http.go +++ b/pkg/probes/http.go @@ -3,7 +3,7 @@ package probes import ( "crypto/tls" "fmt" - "io/ioutil" + "io" "net/http" "net/url" "time" @@ -11,9 +11,11 @@ import ( var defaultTransport = http.DefaultTransport.(*http.Transport) -// New creates Prober that will skip TLS verification while probing. +const maxProbeBody = 1 << 20 + +// NewHTTPProber creates a prober that verifies HTTPS endpoints using system roots. func NewHTTPProber() HttpProber { - tlsConfig := &tls.Config{InsecureSkipVerify: true} + tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12} return NewWithTLSConfig(tlsConfig) } @@ -29,6 +31,14 @@ func setOldTransportDefaults(t *http.Transport) *http.Transport { } func NewWithTLSConfig(config *tls.Config) HttpProber { + if config == nil { + config = &tls.Config{MinVersion: tls.VersionTLS12} + } else { + config = config.Clone() + if config.MinVersion < tls.VersionTLS12 { + config.MinVersion = tls.VersionTLS12 + } + } transport := setOldTransportDefaults( &http.Transport{ TLSClientConfig: config, @@ -46,8 +56,9 @@ type HttpProber struct { // If the HTTP response code is unsuccessful or HTTP communication fails, it returns Failure. func (pr HttpProber) Probe(url *url.URL, headers http.Header, timeout time.Duration) (ProbeResult, string, error) { client := &http.Client{ - Timeout: timeout, - Transport: pr.transport, + Timeout: timeout, + Transport: pr.transport, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }, } req, err := http.NewRequest("GET", url.String(), nil) @@ -65,10 +76,13 @@ func (pr HttpProber) Probe(url *url.URL, headers http.Header, timeout time.Durat } defer res.Body.Close() - b, err := ioutil.ReadAll(res.Body) + b, err := io.ReadAll(io.LimitReader(res.Body, maxProbeBody+1)) if err != nil { return Failure, "", err } + if len(b) > maxProbeBody { + return Failure, "", fmt.Errorf("HTTP probe response exceeds %d bytes", maxProbeBody) + } body := string(b) if res.StatusCode >= http.StatusOK && res.StatusCode < http.StatusBadRequest { diff --git a/pkg/probes/http_test.go b/pkg/probes/http_test.go new file mode 100644 index 00000000..e4e17c99 --- /dev/null +++ b/pkg/probes/http_test.go @@ -0,0 +1,44 @@ +package probes + +import ( + "crypto/tls" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" +) + +func TestHTTPProberVerifiesTLSByDefault(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { + response.WriteHeader(http.StatusOK) + })) + defer server.Close() + endpoint, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + result, _, err := NewHTTPProber().Probe(endpoint, nil, time.Second) + if err != nil { + t.Fatal(err) + } + if result != Failure { + t.Fatalf("untrusted TLS endpoint result = %s, want failure", result) + } +} + +func TestHTTPProberBoundsResponseBody(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { + _, _ = response.Write([]byte(strings.Repeat("x", maxProbeBody+1))) + })) + defer server.Close() + endpoint, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + _, _, err = NewWithTLSConfig(&tls.Config{}).Probe(endpoint, nil, time.Second) + if err == nil { + t.Fatal("expected oversized response error") + } +} From 8c502eb441eb14655190e63078e7098a29a042e8 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 15:12:59 +0530 Subject: [PATCH 121/149] fix(startup): propagate repository sync failures Signed-off-by: vibhatsu --- api/main.go | 8 ++++---- core/manager/sync.go | 19 +++++++++++++------ core/manager/sync_test.go | 20 ++++++++++++++++++++ 3 files changed, 37 insertions(+), 10 deletions(-) create mode 100644 core/manager/sync_test.go diff --git a/api/main.go b/api/main.go index 3dac9d22..6a5b2296 100644 --- a/api/main.go +++ b/api/main.go @@ -35,9 +35,7 @@ const ( var BeastScheduler scheduler.Scheduler = scheduler.NewScheduler() func runBeastApiBootsteps(defaultauthorpassword string) error { - manager.RunBeastBootsteps(defaultauthorpassword) - - return nil + return manager.RunBeastBootsteps(defaultauthorpassword) } // @title Beast API @@ -141,7 +139,9 @@ func RunBeastApiServer(ctx context.Context, port, defaultauthorpassword string, // Must be started before the Notification Router, since SSE handler has access to SSE Hub sse.Init() - runBeastApiBootsteps(defaultauthorpassword) + if err := runBeastApiBootsteps(defaultauthorpassword); err != nil { + return err + } // Initialize Gin router. router := initGinRouter() diff --git a/core/manager/sync.go b/core/manager/sync.go index 12c7c185..f3a0fc31 100644 --- a/core/manager/sync.go +++ b/core/manager/sync.go @@ -68,7 +68,10 @@ func SyncBeastRemote(defaultauthorpassword string) error { } log.Info("Beast git base synced with remote") UpdateChallenges(defaultauthorpassword) - return fmt.Errorf("%s", strings.Join(errStrings, "\n")) + if len(errStrings) != 0 { + return fmt.Errorf("%s", strings.Join(errStrings, "\n")) + } + return nil } func ResetBeastRemote(defaultauthorpassword string) error { @@ -85,12 +88,14 @@ func ResetBeastRemote(defaultauthorpassword string) error { } } } - err := SyncBeastRemote(defaultauthorpassword) - if err != nil { + if err := SyncBeastRemote(defaultauthorpassword); err != nil { log.Errorf("Error while syncing remote after clean : %s", err) + errStrings = append(errStrings, err.Error()) + } + if len(errStrings) != 0 { + return fmt.Errorf("%s", strings.Join(errStrings, "\n")) } - errors := strings.Join(errStrings, "\n") + err.Error() - return fmt.Errorf("%s", errors) + return nil } // IsAlreadySynced checks if the local repository is already synced @@ -194,6 +199,8 @@ func SyncAndGetChangesFromRemote(defaultauthorpassword string) []string { func RunBeastBootsteps(defaultauthorpassword string) error { log.Info("Syncing beast git challenge dir with remote....") - _ = SyncBeastRemote(defaultauthorpassword) + if err := SyncBeastRemote(defaultauthorpassword); err != nil { + return fmt.Errorf("sync Beast remote: %w", err) + } return nil } diff --git a/core/manager/sync_test.go b/core/manager/sync_test.go new file mode 100644 index 00000000..c1b92863 --- /dev/null +++ b/core/manager/sync_test.go @@ -0,0 +1,20 @@ +package manager + +import ( + "testing" + + "github.com/sdslabs/beastv4/core/config" +) + +func TestSyncWithoutActiveRemotesSucceeds(t *testing.T) { + previous := config.Cfg + config.Cfg = &config.BeastConfig{} + t.Cleanup(func() { config.Cfg = previous }) + + if err := SyncBeastRemote(""); err != nil { + t.Fatalf("SyncBeastRemote() = %v, want nil", err) + } + if err := ResetBeastRemote(""); err != nil { + t.Fatalf("ResetBeastRemote() = %v, want nil", err) + } +} From c03c9a9c15bc96c174e48ac6a36eb955fe0a3189 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 15:13:54 +0530 Subject: [PATCH 122/149] fix(lifecycle): keep backups off critical paths Signed-off-by: vibhatsu --- cmd/beast/run.go | 19 +------------------ core/manager/health_check.go | 2 -- 2 files changed, 1 insertion(+), 20 deletions(-) diff --git a/cmd/beast/run.go b/cmd/beast/run.go index 171ff6c3..1d5a7f18 100644 --- a/cmd/beast/run.go +++ b/cmd/beast/run.go @@ -142,17 +142,9 @@ 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.Close() + err := cache.Close() if err != nil { log.Errorln("Unable to terminate cache connections:", err) } else { @@ -161,15 +153,6 @@ func cleanupCacheConnections() { } func cleanupDatabaseConnections() { - log.Infoln("Backing up database...") - - err := database.BackupDatabase() - if err != nil { - log.Errorln("Error while backing up database:", err) - } else { - log.Infoln("Database backup completed successfully") - } - log.Infoln("Terminating database connection...") if database.Db == nil { diff --git a/core/manager/health_check.go b/core/manager/health_check.go index 6a52aa3f..0ce22e7c 100644 --- a/core/manager/health_check.go +++ b/core/manager/health_check.go @@ -160,8 +160,6 @@ func runHealthCheckCycle(waitTime int) { checks := []func(){ func() { ChallengesHealthProber(waitTime) }, func() { ServerHealthProber(waitTime) }, - func() { _ = database.BackupDatabase() }, - func() { _ = cache.BackupCache() }, } group.Add(len(checks)) for _, check := range checks { From bf9a43c3f12964736cdb9c658d1ff9d0026bc4b0 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 15:15:17 +0530 Subject: [PATCH 123/149] fix(cli): preserve existing generated files Signed-off-by: vibhatsu --- cmd/beast/cmdref.go | 27 +++--- cmd/beast/generate_template.go | 139 ++++++++++------------------ cmd/beast/generate_template_test.go | 41 ++++++++ cmd/beast/version.go | 3 +- 4 files changed, 102 insertions(+), 108 deletions(-) create mode 100644 cmd/beast/generate_template_test.go diff --git a/cmd/beast/cmdref.go b/cmd/beast/cmdref.go index a53959c2..3a355208 100644 --- a/cmd/beast/cmdref.go +++ b/cmd/beast/cmdref.go @@ -1,7 +1,9 @@ package main import ( - log "github.com/sirupsen/logrus" + "fmt" + "os" + "github.com/spf13/cobra" "github.com/spf13/cobra/doc" ) @@ -10,20 +12,15 @@ import ( var cmdRef = &cobra.Command{ Use: "cmdref [-r]", Short: "Generate beast command reference", - Run: func(cmd *cobra.Command, args []string) { - - if RefDirectory != "" { - err := doc.GenMarkdownTree(rootCmd, RefDirectory) - if err != nil { - log.Fatal(err) - - } - } else { - err := doc.GenMarkdownTree(rootCmd, DEFAULT_CMDREF_DIRECTORY) - if err != nil { - log.Fatal(err) - - } + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + directory := RefDirectory + if directory == "" { + directory = DEFAULT_CMDREF_DIRECTORY + } + if err := os.MkdirAll(directory, 0750); err != nil { + return fmt.Errorf("create command reference directory: %w", err) } + return doc.GenMarkdownTree(rootCmd, directory) }, } diff --git a/cmd/beast/generate_template.go b/cmd/beast/generate_template.go index 24022974..eb317299 100644 --- a/cmd/beast/generate_template.go +++ b/cmd/beast/generate_template.go @@ -2,118 +2,75 @@ package main import ( "bytes" + "fmt" "os" "path/filepath" "text/template" "github.com/sdslabs/beastv4/core" - "github.com/sdslabs/beastv4/core/config" + challengeConfig "github.com/sdslabs/beastv4/core/config" tools "github.com/sdslabs/beastv4/templates" - log "github.com/sirupsen/logrus" "github.com/spf13/cobra" ) var generateTemplateCmd = &cobra.Command{ Use: "new", - Short: "generate config file", - Long: "generate basic challenge config and public directory ", - - Run: func(cmd *cobra.Command, args []string) { - log.Debug("Generating Config Template") - - path, err := os.Getwd() - if err != nil { - log.Errorf("Error while finding directory's path :: %s : using empty string instead", err) - } - - challName := filepath.Base(path) - if challName == "." { - challName = "" - } - - var config config.BeastChallengeConfig - config.PopulateDefaultValues() - - var configfile bytes.Buffer - log.Debugf("Preparing Config template") - configfileTemplate, err := template.New("configfile").Parse(tools.CHALLENGE_CONFIG_FILE_TEMPLATE) - if err != nil { - log.Errorf("Error while parsing configfile template :: %s", err) - return - } - - log.Debugf("Executing dockerfile template with challenge config") - err = configfileTemplate.Execute(&configfile, config) - if err != nil { - log.Errorf("Error while executing configfile template :: %s", err) - return - } - - err = createFile() - if err != nil { - log.Errorf("Error while creating beast.toml :: %s", err) - return - } - - var file, erro = os.OpenFile(core.CHALLENGE_CONFIG_FILE_NAME, os.O_RDWR, 0644) - if erro != nil { - log.Fatal(erro) - } - - _, err = file.WriteString(configfile.String()) - if err != nil { - log.Errorf("Error while writing beast.toml :: %s", err) - } - - defer file.Close() - log.Debugf("beast.toml generated for the challenge") - - err = createPublicDir() + Short: "Generate a challenge configuration and public directory", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + directory, err := os.Getwd() if err != nil { - log.Errorf("Error while creating public directory") + return fmt.Errorf("resolve working directory: %w", err) } - + return generateChallengeTemplate(directory) }, } -func createFile() error { - // check if file exists - _, err := os.Stat(core.CHALLENGE_CONFIG_FILE_NAME) - - // create file if not exists - if os.IsNotExist(err) { - - file, err := os.Create(core.CHALLENGE_CONFIG_FILE_NAME) - if err != nil { - return err +func generateChallengeTemplate(directory string) error { + configPath := filepath.Join(directory, core.CHALLENGE_CONFIG_FILE_NAME) + publicPath := filepath.Join(directory, core.PUBLIC) + for _, path := range []string{configPath, publicPath} { + if _, err := os.Lstat(path); err == nil { + return fmt.Errorf("refusing to overwrite existing path: %s", path) + } else if !os.IsNotExist(err) { + return fmt.Errorf("inspect output path %s: %w", path, err) } - defer file.Close() - return nil - - } else if err != nil { - return err } - log.Errorf("%s already exists", core.CHALLENGE_CONFIG_FILE_NAME) - - return nil -} - -func createPublicDir() error { - // check if public directory exists - _, err := os.Stat(core.PUBLIC) + var configuration challengeConfig.BeastChallengeConfig + configuration.PopulateDefaultValues() + parsed, err := template.New("configfile").Parse(tools.CHALLENGE_CONFIG_FILE_TEMPLATE) + if err != nil { + return fmt.Errorf("parse challenge template: %w", err) + } + var contents bytes.Buffer + if err := parsed.Execute(&contents, configuration); err != nil { + return fmt.Errorf("render challenge template: %w", err) + } - // create public directory if not exists - if os.IsNotExist(err) { - err = os.MkdirAll(core.PUBLIC, 0755) - if err != nil { - return err + file, err := os.OpenFile(configPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if err != nil { + return fmt.Errorf("create %s: %w", configPath, err) + } + keepConfig := false + defer func() { + _ = file.Close() + if !keepConfig { + _ = os.Remove(configPath) } - return nil - } else if err != nil { - return err + }() + if _, err := file.Write(contents.Bytes()); err != nil { + return fmt.Errorf("write %s: %w", configPath, err) } - - log.Errorf("%s directory already exists", core.PUBLIC) + if err := file.Sync(); err != nil { + return fmt.Errorf("sync %s: %w", configPath, err) + } + if err := file.Close(); err != nil { + return fmt.Errorf("close %s: %w", configPath, err) + } + if err := os.Mkdir(publicPath, 0750); err != nil { + return fmt.Errorf("create public directory: %w", err) + } + keepConfig = true return nil } diff --git a/cmd/beast/generate_template_test.go b/cmd/beast/generate_template_test.go new file mode 100644 index 00000000..4912b115 --- /dev/null +++ b/cmd/beast/generate_template_test.go @@ -0,0 +1,41 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + "github.com/sdslabs/beastv4/core" +) + +func TestGenerateChallengeTemplateRefusesOverwrite(t *testing.T) { + directory := t.TempDir() + configPath := filepath.Join(directory, core.CHALLENGE_CONFIG_FILE_NAME) + if err := os.WriteFile(configPath, []byte("keep"), 0600); err != nil { + t.Fatal(err) + } + if err := generateChallengeTemplate(directory); err == nil { + t.Fatal("expected existing template error") + } + contents, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + if string(contents) != "keep" { + t.Fatalf("existing template was modified: %q", contents) + } +} + +func TestGenerateChallengeTemplateUsesPrivateConfig(t *testing.T) { + directory := t.TempDir() + if err := generateChallengeTemplate(directory); err != nil { + t.Fatal(err) + } + info, err := os.Stat(filepath.Join(directory, core.CHALLENGE_CONFIG_FILE_NAME)) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0600 { + t.Fatalf("template mode = %04o, want 0600", info.Mode().Perm()) + } +} diff --git a/cmd/beast/version.go b/cmd/beast/version.go index b4c225a4..bebd31e2 100644 --- a/cmd/beast/version.go +++ b/cmd/beast/version.go @@ -2,7 +2,6 @@ package main import ( "fmt" - "os" "github.com/sdslabs/beastv4/version" "github.com/spf13/cobra" @@ -16,6 +15,7 @@ var versionCmd = &cobra.Command{ Short: "Displays the version of the current build of beast", Long: `Displays the version of the current build of beast, this information include Version, Revision, Git-Branch, BuildUser, BuildDate, go-version`, + Args: cobra.NoArgs, Run: func(cmd *cobra.Command, args []string) { fmt.Printf(version.VersionStr, @@ -25,6 +25,5 @@ include Version, Revision, Git-Branch, BuildUser, BuildDate, go-version`, version.Info["buildUser"], version.Info["buildDate"], version.Info["goVersion"]) - os.Exit(0) }, } From 02f21740f484189c6844e058cfacfaa8be477677 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 15:17:47 +0530 Subject: [PATCH 124/149] fix(staging): reject linked challenge files Signed-off-by: vibhatsu --- core/manager/pipeline.go | 3 ++ core/manager/utils.go | 14 ++++---- utils/file.go | 75 +++++++++++++++++++++++++++------------- utils/file_test.go | 40 +++++++++++++++++++++ 4 files changed, 102 insertions(+), 30 deletions(-) diff --git a/core/manager/pipeline.go b/core/manager/pipeline.go index 5a4f782c..32448199 100644 --- a/core/manager/pipeline.go +++ b/core/manager/pipeline.go @@ -119,6 +119,9 @@ func stageChallenge(challengeDir string, config *cfg.BeastChallengeConfig) error } log.Debugf("Copying challenge config to staging directory") + if err := utils.RemoveFileIfExists(filepath.Join(stagingDir, core.CHALLENGE_CONFIG_FILE_NAME)); err != nil { + return fmt.Errorf("remove previous staged challenge config: %w", err) + } err = utils.CopyFile(challengeConfig, filepath.Join(stagingDir, core.CHALLENGE_CONFIG_FILE_NAME)) if err != nil { return fmt.Errorf("error while copying challenge config to staging : %s", err) diff --git a/core/manager/utils.go b/core/manager/utils.go index fe50b6f4..f1ec0b85 100644 --- a/core/manager/utils.go +++ b/core/manager/utils.go @@ -703,19 +703,21 @@ func LogTransaction(identifier string, action string, authorization string) erro // Copies the Static content to the staging/static/folder func CopyToStaticContent(challengeName, staticContentDir string) error { dirPath := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_STAGING_DIR, challengeName, core.BEAST_STATIC_FOLDER) - err := utils.CreateIfNotExistDir(dirPath) - if err != nil { - return fmt.Errorf("error while copying static content : %v", err) + if _, err := os.Lstat(dirPath); err == nil { + if err := utils.RemoveDirRecursively(dirPath); err != nil { + return fmt.Errorf("clear static content: %w", err) + } + } else if !os.IsNotExist(err) { + return fmt.Errorf("inspect static content destination: %w", err) } - err = utils.ValidateDirExists(staticContentDir) + err := utils.ValidateDirExists(staticContentDir) if err != nil { log.Warnf("%s : There is no static directory inside challenge, skipping copy.", challengeName) return nil } - err = utils.CopyDirectory(staticContentDir, dirPath) - return err + return utils.CopyDirectory(staticContentDir, dirPath) } func GetAvailableChallenges() ([]string, error) { diff --git a/utils/file.go b/utils/file.go index c568787e..75c3b85b 100644 --- a/utils/file.go +++ b/utils/file.go @@ -4,9 +4,7 @@ import ( "errors" "fmt" "io" - "io/ioutil" "os" - "path" "path/filepath" "strings" @@ -145,14 +143,19 @@ func ResolvePathWithin(root, relativePath string) (string, error) { // if there was an error while creating the directory it returns the error // else it returns nil indicating success func CreateIfNotExistDir(dirPath string) error { - err := ValidateDirExists(dirPath) - if err != nil { - if e := os.MkdirAll(dirPath, 0755); e != nil { - eMsg := fmt.Errorf("could not create directory : %s", dirPath) - return eMsg + info, err := os.Lstat(dirPath) + if os.IsNotExist(err) { + if err := os.MkdirAll(dirPath, 0750); err != nil { + return fmt.Errorf("create directory %s: %w", dirPath, err) } + info, err = os.Lstat(dirPath) + } + if err != nil { + return fmt.Errorf("inspect directory %s: %w", dirPath, err) + } + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("path is not a real directory: %s", dirPath) } - return nil } @@ -199,9 +202,12 @@ func RemoveDirRecursively(dirPath string) error { } func CopyFile(src, dst string) error { - err := ValidateFileExists(src) + sourceInfo, err := os.Lstat(src) if err != nil { - return err + return fmt.Errorf("inspect source file: %w", err) + } + if !sourceInfo.Mode().IsRegular() { + return fmt.Errorf("source is not a regular file: %s", src) } source, err := os.Open(src) @@ -209,36 +215,56 @@ func CopyFile(src, dst string) error { return err } defer source.Close() - - destination, err := os.OpenFile(dst, os.O_RDWR|os.O_CREATE, 0666) + openedInfo, err := source.Stat() if err != nil { - return fmt.Errorf("error while creating destination file : %s", err) + return fmt.Errorf("inspect opened source file: %w", err) + } + if !openedInfo.Mode().IsRegular() || !os.SameFile(sourceInfo, openedInfo) { + return fmt.Errorf("source changed while opening: %s", src) } - defer destination.Close() - _, err = io.Copy(destination, source) - return err + destination, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, sourceInfo.Mode().Perm()) + if err != nil { + return fmt.Errorf("create destination file: %w", err) + } + if _, err := io.Copy(destination, source); err != nil { + _ = destination.Close() + _ = os.Remove(dst) + return fmt.Errorf("copy file contents: %w", err) + } + if err := destination.Close(); err != nil { + _ = os.Remove(dst) + return fmt.Errorf("close destination file: %w", err) + } + return nil } func CopyDirectory(src, dst string) error { - srcInfo, err := os.Stat(src) + srcInfo, err := os.Lstat(src) if err != nil { return err } + if !srcInfo.IsDir() || srcInfo.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("source is not a real directory: %s", src) + } - err = os.MkdirAll(dst, srcInfo.Mode()) - if err != nil { + if err := os.Mkdir(dst, srcInfo.Mode().Perm()); err != nil { return err } + complete := false + defer func() { + if !complete { + _ = os.RemoveAll(dst) + } + }() - fds, err := ioutil.ReadDir(src) - + fds, err := os.ReadDir(src) if err != nil { return err } for _, fd := range fds { - srcn := path.Join(src, fd.Name()) - dstn := path.Join(dst, fd.Name()) + srcn := filepath.Join(src, fd.Name()) + dstn := filepath.Join(dst, fd.Name()) if fd.IsDir() { if err = CopyDirectory(srcn, dstn); err != nil { @@ -250,6 +276,7 @@ func CopyDirectory(src, dst string) error { } } } + complete = true return nil } @@ -299,7 +326,7 @@ func GetDirsInDir(dirPath string) (error, []string) { return err, dirs } - files, err := ioutil.ReadDir(dirPath) + files, err := os.ReadDir(dirPath) if err != nil { return fmt.Errorf("error while reading directory with path %s : %s", dirPath, err), dirs } diff --git a/utils/file_test.go b/utils/file_test.go index 7be400ff..b98876f4 100644 --- a/utils/file_test.go +++ b/utils/file_test.go @@ -100,3 +100,43 @@ func TestExpandHomePathOnlyExpandsHomePrefix(t *testing.T) { t.Fatalf("unexpected arbitrary expansion: %q, %v", got, err) } } + +func TestCopyDirectoryRejectsSymlinks(t *testing.T) { + source := t.TempDir() + target := filepath.Join(t.TempDir(), "copied") + secret := filepath.Join(t.TempDir(), "secret") + if err := os.WriteFile(secret, []byte("host secret"), 0600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(secret, filepath.Join(source, "asset")); err != nil { + t.Fatal(err) + } + if err := CopyDirectory(source, target); err == nil { + t.Fatal("expected symlink rejection") + } + if _, err := os.Lstat(target); !os.IsNotExist(err) { + t.Fatalf("partial destination was not removed: %v", err) + } +} + +func TestCopyFileRefusesExistingDestination(t *testing.T) { + directory := t.TempDir() + source := filepath.Join(directory, "source") + destination := filepath.Join(directory, "destination") + if err := os.WriteFile(source, []byte("new"), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(destination, []byte("keep"), 0600); err != nil { + t.Fatal(err) + } + if err := CopyFile(source, destination); err == nil { + t.Fatal("expected existing destination error") + } + contents, err := os.ReadFile(destination) + if err != nil { + t.Fatal(err) + } + if string(contents) != "keep" { + t.Fatalf("destination was overwritten: %q", contents) + } +} From cae8ed6996e9f2fa5b6a0a250a3d76a2995fa69c Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 15:23:29 +0530 Subject: [PATCH 125/149] fix(static): isolate public asset serving Signed-off-by: vibhatsu --- _examples/.static.beast.htpasswd | 3 -- _examples/README.md | 1 - api/manage.go | 12 ++++-- core/constants.go | 4 +- core/manager/static.go | 48 +++++++++++------------ core/manager/utils.go | 17 ++++++++- extras/static-content/Dockerfile | 31 +++------------ extras/static-content/README.md | 19 +++------ extras/static-content/beast.conf | 55 +++++++-------------------- extras/static-content/docker-entry.sh | 4 -- scripts/build/extras.sh | 30 +++------------ 11 files changed, 81 insertions(+), 143 deletions(-) delete mode 100644 _examples/.static.beast.htpasswd delete mode 100644 extras/static-content/docker-entry.sh diff --git a/_examples/.static.beast.htpasswd b/_examples/.static.beast.htpasswd deleted file mode 100644 index 76ddfc7d..00000000 --- a/_examples/.static.beast.htpasswd +++ /dev/null @@ -1,3 +0,0 @@ -# Password is fristonio_beast_static_pass -# Username fristonio -fristonio:$apr1$SmANaQLf$LSOCPJhgYsqs6ayf.QS3K. diff --git a/_examples/README.md b/_examples/README.md index c48d6c6d..ca708107 100644 --- a/_examples/README.md +++ b/_examples/README.md @@ -5,7 +5,6 @@ ### Configuration file samples: * [Beast global configuration sample](./example.config.toml) -* [Beast static container authentication file](./.static.beast.htpasswd) ### Sample Challenges diff --git a/api/manage.go b/api/manage.go index 1b512e4c..61b6174f 100644 --- a/api/manage.go +++ b/api/manage.go @@ -267,14 +267,20 @@ func beastStaticContentHandler(c *gin.Context) { // Deploy and Undeploy switch action { case core.MANAGE_ACTION_DEPLOY: - go manager.DeployStaticContentContainer() + if err := manager.DeployStaticContentContainer(); err != nil { + c.JSON(http.StatusBadRequest, HTTPPlainResp{Message: err.Error()}) + return + } c.JSON(http.StatusOK, HTTPPlainResp{ - Message: "Static container deploy started", + Message: "Static container deployed", }) return case core.MANAGE_ACTION_UNDEPLOY: - go manager.UndeployStaticContentContainer() + if err := manager.UndeployStaticContentContainer(); err != nil { + c.JSON(http.StatusBadRequest, HTTPPlainResp{Message: err.Error()}) + return + } c.JSON(http.StatusOK, HTTPPlainResp{ Message: "Static content container undeploy started", }) diff --git a/core/constants.go b/core/constants.go index 27b5539f..11046026 100644 --- a/core/constants.go +++ b/core/constants.go @@ -78,10 +78,10 @@ const ( // chall actions const ( // chall env MAX_PORT_PER_CHALL uint32 = 3 - BEAST_CHALLENGES_STATIC_PORT uint32 = 80 + BEAST_CHALLENGES_STATIC_PORT uint32 = 8034 + BEAST_STATIC_CONTAINER_PORT uint32 = 8080 DEFAULT_BASE_IMAGE string = "ubuntu:24.04" DEFAULT_XINETD_CONF_FILE string = "xinetd.conf" - BEAST_STATIC_AUTH_FILE string = ".static.beast.htpasswd" ALLOWED_MIN_PORT_VALUE uint32 = 10000 ALLOWED_MAX_PORT_VALUE uint32 = 20000 ) diff --git a/core/manager/static.go b/core/manager/static.go index 3ad31d15..3ca20b2a 100644 --- a/core/manager/static.go +++ b/core/manager/static.go @@ -3,7 +3,9 @@ package manager import ( "errors" "fmt" + "os" "path/filepath" + "strings" "github.com/sdslabs/beastv4/core" cfg "github.com/sdslabs/beastv4/core/config" @@ -21,9 +23,8 @@ import ( // The image name for the static content docker image shoule be specified in the // BEAST_STATIC_CONTAINER_NAME:latest variable // This function does not build the image for static containers. -// The port for the deployment of the static container is specified in the variable -// BEAST_CHALLENGES_STATIC_PORT, this port should be free and will be the port on which -// nginx container for static files will be running. +// The static container receives a separate read-only mount for each challenge's public +// assets. Other staging data is never visible to the web server. // // Each challenges have its own static file folder inside the challenges directory. // The whole staging area of beast configuration is mounted on the docker container @@ -48,28 +49,26 @@ func DeployStaticContentContainer() error { return errors.New("IMAGE_NOT_FOUND_ERROR") } - // Remove the prefix sha256: - imageId := images[0].ID[7:] - stagingDirPath := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_STAGING_DIR) - err = utils.CreateIfNotExistDir(stagingDirPath) - if err != nil { - log.Errorf("Error in validating staging mount point : %s", err) - return errors.New("INVALID_STAGING_AREA") - } + imageId := strings.TrimPrefix(images[0].ID, "sha256:") - beastStaticAuthFile := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_STATIC_AUTH_FILE) - err = utils.ValidateFileExists(beastStaticAuthFile) + staticMount := make(map[string]string) + challenges, err := database.QueryAllChallenges() if err != nil { - p := fmt.Errorf("BEAST STATIC: Authentication file does not exist for beast static container, cannot proceed deployment") - log.Error(p.Error()) - return p + return fmt.Errorf("query static challenge assets: %w", err) + } + for _, challenge := range challenges { + source := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_STAGING_DIR, challenge.Name, core.BEAST_STATIC_FOLDER) + info, statErr := os.Lstat(source) + if os.IsNotExist(statErr) { + continue + } + if statErr != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("invalid static asset directory for %s", challenge.Name) + } + staticMount[source] = filepath.Join(core.BEAST_STAGING_AREA_MOUNT_POINT, challenge.Name, core.BEAST_STATIC_FOLDER) } - - staticMount := make(map[string]string) - staticMount[stagingDirPath] = core.BEAST_STAGING_AREA_MOUNT_POINT - staticMount[beastStaticAuthFile] = filepath.Join("/", core.BEAST_STATIC_AUTH_FILE) portMap := cr.PortMapping{ - ContainerPort: core.BEAST_CHALLENGES_STATIC_PORT, + ContainerPort: core.BEAST_STATIC_CONTAINER_PORT, HostPort: core.BEAST_CHALLENGES_STATIC_PORT, } @@ -101,13 +100,14 @@ func DeployStaticContentContainer() error { // This cleans up the container deployed by DeployStaticContentContainer function // The image is preserved after calling the function and thus need not be build again. -func UndeployStaticContentContainer() { +func UndeployStaticContentContainer() error { err := coreutils.CleanupContainerByFilter("name", core.BEAST_STATIC_CONTAINER_NAME) if err != nil { log.Errorf("Error while cleaning old static content container : %s", err) - } else { - log.Infof("Static content container undeployed") + return err } + log.Infof("Static content container undeployed") + return nil } // Deploy a static challenge diff --git a/core/manager/utils.go b/core/manager/utils.go index f1ec0b85..7c3462f3 100644 --- a/core/manager/utils.go +++ b/core/manager/utils.go @@ -717,7 +717,22 @@ func CopyToStaticContent(challengeName, staticContentDir string) error { return nil } - return utils.CopyDirectory(staticContentDir, dirPath) + if err := utils.CopyDirectory(staticContentDir, dirPath); err != nil { + return err + } + return filepath.WalkDir(dirPath, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if entry.Type()&os.ModeSymlink != 0 { + return fmt.Errorf("static asset is a symbolic link: %s", path) + } + mode := os.FileMode(0644) + if entry.IsDir() { + mode = 0755 + } + return os.Chmod(path, mode) + }) } func GetAvailableChallenges() ([]string, error) { diff --git a/extras/static-content/Dockerfile b/extras/static-content/Dockerfile index c2905989..b4c00469 100644 --- a/extras/static-content/Dockerfile +++ b/extras/static-content/Dockerfile @@ -1,27 +1,8 @@ -FROM nginx:latest +FROM nginxinc/nginx-unprivileged:1.28.0-alpine@sha256:c97ff0bf7cbae369953c6da1232ec14ad9f971d66360c5698db0856a4cd657a0 -LABEL version="0.1" -LABEL author="fristonio" +USER root +RUN mkdir -p /beast && chown 101:101 /beast +COPY --chown=101:101 beast.conf /etc/nginx/conf.d/default.conf -RUN apt-get update \ - && apt-get install -y -q --no-install-recommends ca-certificates less \ - && apt-get clean \ - && rm -r /var/lib/apt/lists/* - -RUN sed -i 's/^http {/&\n server_names_hash_bucket_size 128;/g' /etc/nginx/nginx.conf -RUN chown nginx:nginx /var/log/nginx/ - -COPY beast.conf /etc/nginx/conf.d/default.conf -ADD docker-entry.sh /docker-entry.sh -RUN chmod +x docker-entry.sh - -VOLUME ["/beast"] -EXPOSE 80 - -# Add tini -ENV TINI_VERSION v0.18.0 -ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini /tini -RUN chmod +x /tini -ENTRYPOINT ["/tini", "--"] - -CMD ["/docker-entry.sh"] +USER 101 +EXPOSE 8080 diff --git a/extras/static-content/README.md b/extras/static-content/README.md index 78842a8c..55bdf782 100644 --- a/extras/static-content/README.md +++ b/extras/static-content/README.md @@ -10,20 +10,11 @@ Build the docker image using $ docker build . --tag beast-static:latest ``` -To run the nginx powered static content serving container on port 8034 (by standard) for beast, run +Beast deploys the image on host port 8034 through the authenticated management API. It mounts only each challenge's public static directory read-only; do not mount the complete Beast staging directory. ```bash -$ export BEAST_STATIC_PORT=8034 -$ docker run -d -p $BEAST_STATIC_PORT:80 -v :/beast -v :/.static.beast.htpasswd beast-static -``` - -For authentication purposes you should create a htpasswd file using apache2-utils. First install apache2-utils and then create a htpasswd file - -```bash -$ sudo apt-get install -y apache2-utils - -$ htpasswd -c .static.beast.htpasswd - - -$ mv .static.beast.htpasswd ~/.beast/ +$ make requirements +$ beast run +$ curl --cacert -H 'Authorization: Bearer ' \ + -X POST https://localhost:5005/api/manage/static/deploy ``` diff --git a/extras/static-content/beast.conf b/extras/static-content/beast.conf index b2e2da04..382c3790 100644 --- a/extras/static-content/beast.conf +++ b/extras/static-content/beast.conf @@ -1,46 +1,19 @@ -# Make sure you have static.beast.sdslabs.co in your /etc/hosts file -# for local deployment and testing. - server { - listen [::]:80; - listen 80; - - server_name static.beast.sdslabs.co; - - access_log /var/log/nginx/static.beast.access.log; - error_log /var/log/nginx/static.beast.error.log; - - root /beast/; - - location ~ /static/(?.+)/static/(?.+)$ { - alias /beast/$chall/static/; - - try_files $file $file/; + listen [::]:8080; + listen 8080; + server_name _; + + access_log /dev/stdout; + error_log /dev/stderr warn; + server_tokens off; + + location ~ "^/static/(?[a-z0-9][a-z0-9_.-]{0,127})/static/(?[A-Za-z0-9_-][A-Za-z0-9._-]*(?:/[A-Za-z0-9_-][A-Za-z0-9._-]*)*)$" { + alias /beast/$chall/static/$asset; + disable_symlinks on; + add_header X-Content-Type-Options nosniff always; } -} - -# Make sure you have static.staging.beast.sdslabs.co in your /etc/hosts file -# for local deployment and testing. - -server { - listen [::]:80; - listen 80; - - server_name static.staging.beast.sdslabs.co; - - access_log /var/log/nginx/static.staging.beast.access.log; - error_log /var/log/nginx/static.staging.beast.error.log; - - auth_basic "Administrator Area."; - auth_basic_user_file /.static.beast.htpasswd; - - root /beast/; - autoindex on; - # Restrict access to files like beast.toml, challenge.tar.gz so we - # don't leak any sensitive information from here. At some point we will - # make the tar file accessible. - location ~ \.(toml|conf|json|env|tar.gz)$ { - return 403; + location / { + return 404; } } diff --git a/extras/static-content/docker-entry.sh b/extras/static-content/docker-entry.sh deleted file mode 100644 index ef423eaa..00000000 --- a/extras/static-content/docker-entry.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/bash - -/etc/init.d/nginx start -exec tail -f /var/log/nginx/* diff --git a/scripts/build/extras.sh b/scripts/build/extras.sh index d1b22de2..27069bc2 100755 --- a/scripts/build/extras.sh +++ b/scripts/build/extras.sh @@ -1,28 +1,8 @@ -#!/bin/bash +#!/usr/bin/env bash -set -euxo pipefail +set -euo pipefail -CWD=$PWD - -BEAST_STATIC_PORT=8034 - -echo -e "\nBuilding static content server for beast...\n" -cd "${CWD}/extras/static-content" - -if docker images | grep -q 'beast-static'; then - echo "Image for static-content already exists." -else - docker build . --tag beast-static:latest -fi - -if docker ps -a | grep -q 'beast-static'; then - echo "Container for static-content already exists." -else - docker run -d -p $BEAST_STATIC_PORT:80 \ - -v ~/.beast/staging:/beast \ - -v ~/.beast/.static.beast.htpasswd:/.static.beast.htpasswd \ - beast-static -fi - -echo "Extras build script complete." +repo_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) +docker build --pull --tag beast-static:latest "${repo_dir}/extras/static-content" +echo "Built beast-static:latest. Deploy it through the authenticated Beast management API." From 56123ee45d87c76353d5c6169240ed18637d7a9e Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 15:27:21 +0530 Subject: [PATCH 126/149] perf(database): allow concurrent reads Signed-off-by: vibhatsu --- core/database/challenges.go | 66 +++++++++++++++++--------------- core/database/database.go | 4 +- core/database/database_test.go | 6 +++ core/database/hints.go | 16 ++++---- core/database/notification.go | 8 ++-- core/database/otp.go | 4 +- core/database/ports.go | 4 +- core/database/submission_test.go | 2 +- core/database/tag.go | 12 +++--- core/database/user.go | 60 ++++++++++++++--------------- 10 files changed, 96 insertions(+), 86 deletions(-) diff --git a/core/database/challenges.go b/core/database/challenges.go index 621b3548..7884aa78 100644 --- a/core/database/challenges.go +++ b/core/database/challenges.go @@ -158,8 +158,8 @@ func CreateChallengeEntry(challenge *Challenge) error { func QueryAllChallenges() ([]Challenge, error) { var challenges []Challenge - DBMux.Lock() - defer DBMux.Unlock() + DBMux.RLock() + defer DBMux.RUnlock() tx := Db.Preload("Ports").Preload("Tags").Find(&challenges) @@ -173,8 +173,8 @@ func QueryAllChallenges() ([]Challenge, error) { func QueryAllChallengesMetadata() ([]Challenge, error) { var challenges []Challenge - DBMux.Lock() - defer DBMux.Unlock() + DBMux.RLock() + defer DBMux.RUnlock() tx := Db.Select("id", "name", "created_at", "points", "difficulty", "instanced", "instance_expiration", "status"). Preload("Tags"). @@ -198,8 +198,8 @@ func QueryChallengeEntries(key string, value string) ([]Challenge, error) { var challenges []Challenge - DBMux.Lock() - defer DBMux.Unlock() + DBMux.RLock() + defer DBMux.RUnlock() tx := Db.Preload("Tags").Preload("Ports").Where(queryKey, value).Find(&challenges) if errors.Is(tx.Error, gorm.ErrRecordNotFound) { @@ -215,12 +215,16 @@ func QueryChallengeEntries(key string, value string) ([]Challenge, error) { // QueryChallengeEntriesMetadata returns only selected columns: Name, ID, Tags, CreatedAt, Points, Difficulty, Instanced, InstanceExpiration, Status func QueryChallengeEntriesMetadata(key string, value string) ([]Challenge, error) { - queryKey := fmt.Sprintf("%s = ?", key) + column, err := validatedQueryColumn(key, "id", "name", "status", "container_id") + if err != nil { + return nil, err + } + queryKey := fmt.Sprintf("%s = ?", column) var challenges []Challenge - DBMux.Lock() - defer DBMux.Unlock() + DBMux.RLock() + defer DBMux.RUnlock() // Only select the required columns, but preload Tags for tag names tx := Db.Select("id", "name", "created_at", "points", "difficulty", "instanced", "instance_expiration", "status"). @@ -244,8 +248,8 @@ func QueryChallengeEntriesMap(whereMap map[string]interface{}) ([]Challenge, err var challenges []Challenge - DBMux.Lock() - defer DBMux.Unlock() + DBMux.RLock() + defer DBMux.RUnlock() tx := Db.Where(whereMap).Find(&challenges) if errors.Is(tx.Error, gorm.ErrRecordNotFound) { @@ -395,8 +399,8 @@ func BatchUpdateChallenge(whereMap map[string]interface{}, chall Challenge) erro func GetRelatedTags(challenge *Challenge) ([]Tag, error) { var tags []Tag - DBMux.Lock() - defer DBMux.Unlock() + DBMux.RLock() + defer DBMux.RUnlock() if err := Db.Model(challenge).Association("Tags").Error; err != nil { return tags, err @@ -409,8 +413,8 @@ func GetRelatedTags(challenge *Challenge) ([]Tag, error) { func GetRelatedUsers(challenge *Challenge) ([]User, error) { var users []User - DBMux.Lock() - defer DBMux.Unlock() + DBMux.RLock() + defer DBMux.RUnlock() // Query users who have solved this challenge by checking the user_challenges table if err := Db.Joins("JOIN user_challenges ON users.id = user_challenges.user_id"). @@ -431,8 +435,8 @@ func GetChallengeSolveInfo(challengeID uint, userID uint) (uint16, bool, error) } var res result - DBMux.Lock() - defer DBMux.Unlock() + DBMux.RLock() + defer DBMux.RUnlock() err := Db.Raw(` SELECT @@ -477,8 +481,8 @@ func DeleteChallengeEntry(challenge *Challenge) error { func QueryAllSubmissions() ([]UserChallenges, error) { var userChallenges []UserChallenges - DBMux.Lock() - defer DBMux.Unlock() + DBMux.RLock() + defer DBMux.RUnlock() tx := Db.Find(&userChallenges) @@ -492,8 +496,8 @@ func QueryAllSubmissions() ([]UserChallenges, error) { func QuerySubmissionsWithPagination(limit, offset int) ([]UserChallenges, error) { var userChallenges []UserChallenges - DBMux.Lock() - defer DBMux.Unlock() + DBMux.RLock() + defer DBMux.RUnlock() tx := Db.Table("user_challenges"). Select("user_challenges.*"). @@ -515,8 +519,8 @@ func QuerySubmissionsWithPagination(limit, offset int) ([]UserChallenges, error) func QuerySubmissions(whereMap map[string]interface{}) ([]UserChallenges, error) { var userChallenges []UserChallenges - DBMux.Lock() - defer DBMux.Unlock() + DBMux.RLock() + defer DBMux.RUnlock() tx := Db.Where(whereMap).Find(&userChallenges) if errors.Is(tx.Error, gorm.ErrRecordNotFound) { @@ -590,8 +594,8 @@ func CreateDynamicFlagEntry(dynamicFlag *DynamicFlag) error { func QueryDynamicFlagEntries(whereMap map[string]interface{}) ([]DynamicFlag, error) { var dynamicFlags []DynamicFlag - DBMux.Lock() - defer DBMux.Unlock() + DBMux.RLock() + defer DBMux.RUnlock() whereMap = normalizeDynamicFlagWhereMap(whereMap) tx := Db.Where(whereMap).Find(&dynamicFlags) @@ -679,8 +683,8 @@ func DeleteAllUserChallenges(challengeID uint) error { func QueryChallAttempts(chall_id uint64) ([]ChallengeAttempt, error) { var attempts []ChallengeAttempt - DBMux.Lock() - defer DBMux.Unlock() + DBMux.RLock() + defer DBMux.RUnlock() err := Db.Table("user_challenges"). Select("user_challenges.id as id, user_challenges.user_id as user_id, users.username as username, user_challenges.created_at as solved_at, user_challenges.flag as flag, user_challenges.solved as correct, user_challenges.cheating as cheating"). @@ -700,8 +704,8 @@ func QueryChallAttempts(chall_id uint64) ([]ChallengeAttempt, error) { func QueryUserAttempts(user_id uint) ([]ChallengeAttempt, error) { var attempts []ChallengeAttempt - DBMux.Lock() - defer DBMux.Unlock() + DBMux.RLock() + defer DBMux.RUnlock() err := Db.Table("user_challenges"). Select("user_challenges.id as id, user_challenges.user_id as user_id, user_challenges.challenge_id as challenge_id, user_challenges.created_at as solved_at, user_challenges.flag as flag, user_challenges.solved as correct, user_challenges.cheating as cheating"). @@ -822,8 +826,8 @@ func QueryTimeSeriesForTopUsers(topUserId []uint) []UserLeaderboardResp { return results } - DBMux.Lock() - defer DBMux.Unlock() + DBMux.RLock() + defer DBMux.RUnlock() type userChallengeRow struct { UserID uint diff --git a/core/database/database.go b/core/database/database.go index 183c4d41..a8b76369 100644 --- a/core/database/database.go +++ b/core/database/database.go @@ -21,7 +21,7 @@ import ( ) var ( - DBMux *sync.Mutex + DBMux *sync.RWMutex Db *gorm.DB ) @@ -83,7 +83,7 @@ func ConnectDatabase() error { // database, which is not closed after creating a connection here and can // be used further after this. func Init() error { - DBMux = &sync.Mutex{} + DBMux = &sync.RWMutex{} if Db == nil { if err := ConnectDatabase(); err != nil { return fmt.Errorf("initialize database: %w", err) diff --git a/core/database/database_test.go b/core/database/database_test.go index 4ebb129e..8ee9cb1b 100644 --- a/core/database/database_test.go +++ b/core/database/database_test.go @@ -32,3 +32,9 @@ func TestPostgresDSNEncodesCredentials(t *testing.T) { t.Fatalf("sslrootcert did not round trip through DSN: %s", dsn) } } + +func TestChallengeMetadataQueryRejectsUnknownColumn(t *testing.T) { + if _, err := QueryChallengeEntriesMetadata("name OR true", "challenge"); err == nil { + t.Fatal("expected unsafe metadata query column error") + } +} diff --git a/core/database/hints.go b/core/database/hints.go index d45e087f..9ba5004d 100644 --- a/core/database/hints.go +++ b/core/database/hints.go @@ -46,8 +46,8 @@ func CreateHintEntry(hint *Hint) error { } func GetHintByID(hintID uint) (*Hint, error) { - DBMux.Lock() - defer DBMux.Unlock() + DBMux.RLock() + defer DBMux.RUnlock() tx := Db.Begin() if tx.Error != nil { @@ -73,8 +73,8 @@ func GetHintByID(hintID uint) (*Hint, error) { // checks if user has already taken the hint func UserHasTakenHint(userID, hintID uint) (bool, error) { - DBMux.Lock() - defer DBMux.Unlock() + DBMux.RLock() + defer DBMux.RUnlock() tx := Db.Begin() if tx.Error != nil { @@ -156,8 +156,8 @@ func QueryHintsTaken(userID, challengeID uint) ([]Hint, error) { var userHints []UserHint var hints []Hint - DBMux.Lock() - defer DBMux.Unlock() + DBMux.RLock() + defer DBMux.RUnlock() tx := Db.Where("user_id = ? AND challenge_id = ?", userID, challengeID).Find(&userHints) if errors.Is(tx.Error, gorm.ErrRecordNotFound) { @@ -188,8 +188,8 @@ func QueryHintsTaken(userID, challengeID uint) ([]Hint, error) { func QueryHintsByChallengeID(challengeID uint) ([]Hint, error) { var hints []Hint - DBMux.Lock() - defer DBMux.Unlock() + DBMux.RLock() + defer DBMux.RUnlock() if err := Db.Where("challenge_id = ?", challengeID).Find(&hints).Error; err != nil { return nil, err diff --git a/core/database/notification.go b/core/database/notification.go index f6495f1f..18aa5bb1 100644 --- a/core/database/notification.go +++ b/core/database/notification.go @@ -65,8 +65,8 @@ func QueryNotificationEntries(key string, value string) ([]Notification, error) var notifications []Notification - DBMux.Lock() - defer DBMux.Unlock() + DBMux.RLock() + defer DBMux.RUnlock() tx := Db.Where(queryKey, value).Find(¬ifications) if errors.Is(tx.Error, gorm.ErrRecordNotFound) { @@ -99,8 +99,8 @@ func QueryFirstNotificationEntry(key string, value string) (Notification, error) func QueryAllNotification() ([]Notification, error) { var notifications []Notification - DBMux.Lock() - defer DBMux.Unlock() + DBMux.RLock() + defer DBMux.RUnlock() tx := Db.Find(¬ifications) if errors.Is(tx.Error, gorm.ErrRecordNotFound) { diff --git a/core/database/otp.go b/core/database/otp.go index 2a42fa7a..72c9a321 100644 --- a/core/database/otp.go +++ b/core/database/otp.go @@ -127,8 +127,8 @@ func ConsumeVerifiedOTP(email, purpose string, now time.Time) error { func QueryOTPEntry(email string) (OTP, error) { var otpEntry OTP - DBMux.Lock() - defer DBMux.Unlock() + DBMux.RLock() + defer DBMux.RUnlock() tx := Db.Where("email = ?", email).First(&otpEntry) diff --git a/core/database/ports.go b/core/database/ports.go index 329318f5..bb50f903 100644 --- a/core/database/ports.go +++ b/core/database/ports.go @@ -39,8 +39,8 @@ func PortEntryGetOrCreate(port *Port) (Port, error) { func GetAllocatedPorts(challenge Challenge) ([]Port, error) { var ports []Port - DBMux.Lock() - defer DBMux.Unlock() + DBMux.RLock() + defer DBMux.RUnlock() if err := Db.Model(&challenge).Association("Ports").Find(&ports); err != nil { return nil, fmt.Errorf("error while searching port for challenge : %s", err) diff --git a/core/database/submission_test.go b/core/database/submission_test.go index fb5e404d..e1ed908f 100644 --- a/core/database/submission_test.go +++ b/core/database/submission_test.go @@ -50,7 +50,7 @@ func setupSubmissionTestDB(t *testing.T) func() { previousDB := Db previousMux := DBMux Db = testDB - DBMux = &sync.Mutex{} + DBMux = &sync.RWMutex{} if err := Db.AutoMigrate(&Challenge{}, &User{}, &UserChallenges{}, &ChallengeMaintainer{}, &DynamicFlag{}, &DynamicFlagClaim{}, &DynamicScoreDirty{}); err != nil { t.Fatalf("auto migrate: %v", err) diff --git a/core/database/tag.go b/core/database/tag.go index 376e9061..d377ec05 100644 --- a/core/database/tag.go +++ b/core/database/tag.go @@ -38,8 +38,8 @@ func QueryRelatedChallenges(tag *Tag) ([]Challenge, error) { var challenges []Challenge var tagName Tag - DBMux.Lock() - defer DBMux.Unlock() + DBMux.RLock() + defer DBMux.RUnlock() Db.Where(&Tag{TagName: tag.TagName}).First(&tagName) @@ -55,8 +55,8 @@ func QueryRelatedChallengesMetadata(tag *Tag) ([]Challenge, error) { var challenges []Challenge var tagName Tag - DBMux.Lock() - defer DBMux.Unlock() + DBMux.RLock() + defer DBMux.RUnlock() Db.Where(&Tag{TagName: tag.TagName}).First(&tagName) @@ -75,8 +75,8 @@ func QueryRelatedChallengesMetadata(tag *Tag) ([]Challenge, error) { func QueryTags(whereMap map[string]interface{}) ([]*Tag, error) { var tags []*Tag - DBMux.Lock() - defer DBMux.Unlock() + DBMux.RLock() + defer DBMux.RUnlock() tx := Db.Where(whereMap).Find(&tags) if errors.Is(tx.Error, gorm.ErrRecordNotFound) { diff --git a/core/database/user.go b/core/database/user.go index ddbfe055..f1f85b51 100644 --- a/core/database/user.go +++ b/core/database/user.go @@ -34,8 +34,8 @@ func QueryUserEntries(key string, value string) ([]User, error) { queryKey := fmt.Sprintf("%s = ?", column) var users []User - DBMux.Lock() - defer DBMux.Unlock() + DBMux.RLock() + defer DBMux.RUnlock() tx := Db.Where(queryKey, value).Find(&users) if errors.Is(tx.Error, gorm.ErrRecordNotFound) { @@ -53,8 +53,8 @@ func QueryUserEntries(key string, value string) ([]User, error) { func QueryAllUsers() ([]User, error) { var users []User - DBMux.Lock() - defer DBMux.Unlock() + DBMux.RLock() + defer DBMux.RUnlock() tx := Db.Find(&users) if errors.Is(tx.Error, gorm.ErrRecordNotFound) { @@ -67,8 +67,8 @@ func QueryAllUsers() ([]User, error) { func QueryUserById(authorID uint) (User, error) { var user User - DBMux.Lock() - defer DBMux.Unlock() + DBMux.RLock() + defer DBMux.RUnlock() tx := Db.First(&user, authorID) if errors.Is(tx.Error, gorm.ErrRecordNotFound) { @@ -83,8 +83,8 @@ func GetUserRank(userID uint, userScore uint, updatedAt time.Time) (rank int64, rank = 1 - DBMux.Lock() - defer DBMux.Unlock() + DBMux.RLock() + defer DBMux.RUnlock() tx := Db.Where("id != ? AND score >= ? AND role = ? AND status = ?", userID, userScore, core.USER_ROLES["contestant"], 0).Find(&users) @@ -139,8 +139,8 @@ func UpdateUser(user *User, m map[string]interface{}) error { func GetRelatedChallenges(user *User) ([]Challenge, error) { var challenges []Challenge - DBMux.Lock() - defer DBMux.Unlock() + DBMux.RLock() + defer DBMux.RUnlock() if err := Db.Preload("Tags").Model(user).Association("Challenges").Find(&challenges); err != nil { return challenges, err @@ -151,8 +151,8 @@ func GetRelatedChallenges(user *User) ([]Challenge, error) { func IsChallengeMaintainer(userID, challengeID uint) (bool, error) { var count int64 - DBMux.Lock() - defer DBMux.Unlock() + DBMux.RLock() + defer DBMux.RUnlock() err := Db.Table("challenge_maintainers"). Where("user_id = ? AND challenge_id = ?", userID, challengeID). Count(&count).Error @@ -181,8 +181,8 @@ func GetUserSolvedChallenges(userID uint) ([]UserSolvedChallenge, error) { } var rows []solveRow - DBMux.Lock() - defer DBMux.Unlock() + DBMux.RLock() + defer DBMux.RUnlock() err := Db.Table("user_challenges"). Select("DISTINCT ON (user_challenges.challenge_id) user_challenges.challenge_id, challenges.name, challenges.type, challenges.points, user_challenges.created_at as solved_at"). @@ -248,8 +248,8 @@ func CheckPreviousSubmissions(userId uint, challId uint) (bool, error) { var count int64 count = 0 - DBMux.Lock() - defer DBMux.Unlock() + DBMux.RLock() + defer DBMux.RUnlock() tx := Db.Where("user_id = ? AND challenge_id = ? AND solved = ?", userId, challId, true).Find(&userChallenges).Count(&count) @@ -263,8 +263,8 @@ func CheckPreviousSubmissions(userId uint, challId uint) (bool, error) { func QueryTopUsersByScore(limit int) ([]User, error) { var users []User - DBMux.Lock() - defer DBMux.Unlock() + DBMux.RLock() + defer DBMux.RUnlock() tx := Db.Where("role = ? AND status = ?", core.USER_ROLES["contestant"], 0). Order("score desc, updated_at asc"). @@ -281,8 +281,8 @@ func QueryTopUsersByScore(limit int) ([]User, error) { func QueryUsersByScoreOffsetLimit(limit, offset int) ([]User, error) { var users []User - DBMux.Lock() - defer DBMux.Unlock() + DBMux.RLock() + defer DBMux.RUnlock() tx := Db.Where("role = ? AND status = ?", core.USER_ROLES["contestant"], 0). Order("score desc, updated_at asc"). @@ -300,8 +300,8 @@ func QueryUsersByScoreOffsetLimit(limit, offset int) ([]User, error) { func QueryTopUsersByFrozenScore(limit int) ([]User, error) { var users []User - DBMux.Lock() - defer DBMux.Unlock() + DBMux.RLock() + defer DBMux.RUnlock() tx := Db.Where("role = ? AND status = ?", core.USER_ROLES["contestant"], 0). Order("frozen_score desc, updated_at asc"). @@ -318,8 +318,8 @@ func QueryTopUsersByFrozenScore(limit int) ([]User, error) { func QueryUsersByFrozenScoreOffsetLimit(limit, offset int) ([]User, error) { var users []User - DBMux.Lock() - defer DBMux.Unlock() + DBMux.RLock() + defer DBMux.RUnlock() tx := Db.Where("role = ? AND status = ?", core.USER_ROLES["contestant"], 0). Order("frozen_score desc, updated_at asc"). @@ -348,8 +348,8 @@ func ResetFrozenScores() error { func IsFrozenScoreSet() (bool, error) { var count int64 - DBMux.Lock() - defer DBMux.Unlock() + DBMux.RLock() + defer DBMux.RUnlock() err := Db.Model(&User{}).Where("frozen_score != 0").Count(&count).Error if err != nil { return false, err @@ -359,8 +359,8 @@ func IsFrozenScoreSet() (bool, error) { func GetUserCount() (int64, error) { var count int64 - DBMux.Lock() - defer DBMux.Unlock() + DBMux.RLock() + defer DBMux.RUnlock() tx := Db.Model(&User{}).Where("role = ?", "contestant").Count(&count) if errors.Is(tx.Error, gorm.ErrRecordNotFound) { return 0, nil @@ -370,8 +370,8 @@ func GetUserCount() (int64, error) { func QueryAllUniqueTags() ([]string, error) { var tags []string - DBMux.Lock() - defer DBMux.Unlock() + DBMux.RLock() + defer DBMux.RUnlock() tx := Db.Model(&Challenge{}).Distinct().Pluck("tag", &tags) if tx.Error != nil { From af4a18abf03b7fb9fe2fe5341b01d4a803fd503e Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 15:30:18 +0530 Subject: [PATCH 127/149] fix(cache): make metadata updates atomic Signed-off-by: vibhatsu --- core/cache/cache.go | 4 +- core/cache/instance.go | 79 +++++++++++++------------------ core/cache/ports.go | 49 ++++++++----------- core/cache/ports_instance_test.go | 2 +- 4 files changed, 58 insertions(+), 76 deletions(-) diff --git a/core/cache/cache.go b/core/cache/cache.go index 43cfcd80..99a0d42c 100644 --- a/core/cache/cache.go +++ b/core/cache/cache.go @@ -20,7 +20,7 @@ import ( ) var ( - CacheMutex *sync.Mutex + CacheMutex *sync.RWMutex Cache *redis.Client ) @@ -116,7 +116,7 @@ func ConnectCache() error { // database, which is not closed after creating a connection here and can // be used further after this. func Init() error { - CacheMutex = &sync.Mutex{} + CacheMutex = &sync.RWMutex{} if Cache == nil { if err := ConnectCache(); err != nil { return fmt.Errorf("initialize cache: %w", err) diff --git a/core/cache/instance.go b/core/cache/instance.go index 9f39c35c..78720b98 100644 --- a/core/cache/instance.go +++ b/core/cache/instance.go @@ -47,26 +47,15 @@ func SaveInstance(instance *Instance, ttl time.Duration) error { } key := utils.InstanceToKey(instance.InstanceID) - err = Cache.Set(ctx, key, data, 0).Err() - if err != nil { - return fmt.Errorf("failed to save instance: %w", err) - } - expiryKey := utils.InstanceExpiryToKey(instance.InstanceID) - err = Cache.Set(ctx, expiryKey, instance.InstanceID, ttl).Err() - if err != nil { - return fmt.Errorf("failed to save instance expiry marker: %w", err) - } - userKey := utils.UserChallengeToKey(instance.UserID, instance.ChallengeName) - err = Cache.Set(ctx, userKey, instance.InstanceID, ttl).Err() - if err != nil { - return fmt.Errorf("failed to save user instance mapping: %w", err) - } - - err = Cache.SAdd(ctx, utils.InstancesSetKey, instance.InstanceID).Err() - if err != nil { - log.Warnf("failed to add instance to set: %v", err) + pipe := Cache.TxPipeline() + pipe.Set(ctx, key, data, 0) + pipe.Set(ctx, expiryKey, instance.InstanceID, ttl) + pipe.Set(ctx, userKey, instance.InstanceID, ttl) + pipe.SAdd(ctx, utils.InstancesSetKey, instance.InstanceID) + if _, err := pipe.Exec(ctx); err != nil { + return fmt.Errorf("save instance metadata: %w", err) } log.Debugf("Saved instance %s for user %s, challenge %s, port %d, expires in %v", @@ -81,8 +70,8 @@ func GetInstance(instanceID string) (*Instance, error) { } ctx := context.Background() - CacheMutex.Lock() - defer CacheMutex.Unlock() + CacheMutex.RLock() + defer CacheMutex.RUnlock() key := utils.InstanceToKey(instanceID) data, err := Cache.Get(ctx, key).Bytes() @@ -105,8 +94,8 @@ func GetUserInstance(userID, challengeName string) (*Instance, error) { } ctx := context.Background() - CacheMutex.Lock() - defer CacheMutex.Unlock() + CacheMutex.RLock() + defer CacheMutex.RUnlock() userKey := utils.UserChallengeToKey(userID, challengeName) instanceID, err := Cache.Get(ctx, userKey).Result() @@ -135,8 +124,8 @@ func GetUserInstances(userID string) ([]*Instance, error) { } ctx := context.Background() - CacheMutex.Lock() - defer CacheMutex.Unlock() + CacheMutex.RLock() + defer CacheMutex.RUnlock() pattern := utils.UserChallengesAllKey(userID) var instances []*Instance @@ -260,16 +249,16 @@ func DeleteInstance(instanceID string) error { return fmt.Errorf("failed to unmarshal instance: %w", err) } - err = Cache.Del(ctx, key).Err() - if err != nil { - return fmt.Errorf("failed to delete instance: %w", err) - } - userKey := utils.UserChallengeToKey(instance.UserID, instance.ChallengeName) expiryKey := utils.InstanceExpiryToKey(instanceID) - Cache.Del(ctx, userKey) - Cache.Del(ctx, expiryKey) - Cache.SRem(ctx, utils.InstancesSetKey, instanceID) + pipe := Cache.TxPipeline() + pipe.Del(ctx, key) + pipe.Del(ctx, userKey) + pipe.Del(ctx, expiryKey) + pipe.SRem(ctx, utils.InstancesSetKey, instanceID) + if _, err := pipe.Exec(ctx); err != nil { + return fmt.Errorf("delete instance metadata: %w", err) + } log.Debugf("Deleted instance %s for user %s, challenge %s", instanceID, instance.UserID, instance.ChallengeName) @@ -312,15 +301,15 @@ func ExtendInstance(instanceID string, additionalTime time.Duration) error { return fmt.Errorf("failed to marshal instance: %w", err) } - err = Cache.Set(ctx, key, updatedData, 0).Err() - if err != nil { - return fmt.Errorf("failed to extend instance: %w", err) - } - userKey := utils.UserChallengeToKey(instance.UserID, instance.ChallengeName) expiryKey := utils.InstanceExpiryToKey(instanceID) - Cache.Set(ctx, expiryKey, instanceID, newTTL) - Cache.Expire(ctx, userKey, newTTL) + pipe := Cache.TxPipeline() + pipe.Set(ctx, key, updatedData, 0) + pipe.Set(ctx, expiryKey, instanceID, newTTL) + pipe.Expire(ctx, userKey, newTTL) + if _, err := pipe.Exec(ctx); err != nil { + return fmt.Errorf("extend instance metadata: %w", err) + } log.Debugf("Extended instance %s by %v, new expiration: %v", instanceID, additionalTime, newExpiresAt) @@ -333,8 +322,8 @@ func CountUserInstances(userID string) (int, error) { } ctx := context.Background() - CacheMutex.Lock() - defer CacheMutex.Unlock() + CacheMutex.RLock() + defer CacheMutex.RUnlock() pattern := utils.UserChallengesAllKey(userID) count := 0 @@ -353,8 +342,8 @@ func GetInstanceTTL(instanceID string) (time.Duration, error) { } ctx := context.Background() - CacheMutex.Lock() - defer CacheMutex.Unlock() + CacheMutex.RLock() + defer CacheMutex.RUnlock() expiryKey := utils.InstanceExpiryToKey(instanceID) ttl, err := Cache.TTL(ctx, expiryKey).Result() @@ -495,8 +484,8 @@ func GetDeletionQueueLength() (int64, error) { } ctx := context.Background() - CacheMutex.Lock() - defer CacheMutex.Unlock() + CacheMutex.RLock() + defer CacheMutex.RUnlock() return Cache.LLen(ctx, utils.InstanceDeletionQueue).Result() } diff --git a/core/cache/ports.go b/core/cache/ports.go index ab9e4b65..85bfc1dd 100644 --- a/core/cache/ports.go +++ b/core/cache/ports.go @@ -36,6 +36,15 @@ end return selected ` +const freeContainerPortsScript = ` +local ports = redis.call("SMEMBERS", KEYS[2]) +for _, port in ipairs(ports) do + redis.call("SREM", KEYS[1], port) +end +redis.call("DEL", KEYS[2]) +return ports +` + // GetFreePortOnHost gets the first available port in the specific range by checking its existance in the cache. // algorithm can be imprived later on if it bottlenecks performance. func GetFreePortOnHost(host string, firstPort uint32, portRange uint32) (uint32, error) { @@ -52,16 +61,13 @@ func GetFreePortOnHost(host string, firstPort uint32, portRange uint32) (uint32, func GetFreePortsOnHost(host string, firstPort uint32, portRange uint32, count int) ([]uint32, error) { if Cache == nil { - Init() + return nil, fmt.Errorf("redis cache not initialized") } if count <= 0 { return []uint32{}, nil } - CacheMutex.Lock() - defer CacheMutex.Unlock() - ctx := context.Background() hostKey := utils.HostToKey(host) @@ -93,6 +99,9 @@ func AssignFreePortOnHostToContainer(host string, containerId string, port uint3 } func AssignPortsOnHostToContainer(host string, containerId string, ports []uint32) error { + if Cache == nil { + return fmt.Errorf("redis cache not initialized") + } CacheMutex.Lock() defer CacheMutex.Unlock() @@ -129,11 +138,11 @@ func AssignPortsOnHostToContainer(host string, containerId string, ports []uint3 // GetContainerPortsOnHost gets all the assigned ports for a given container on a given host func GetContainerPortsOnHost(host string, containerId string) ([]uint32, error) { if Cache == nil { - Init() + return nil, fmt.Errorf("redis cache not initialized") } - CacheMutex.Lock() - defer CacheMutex.Unlock() + CacheMutex.RLock() + defer CacheMutex.RUnlock() ctx := context.Background() instanceKey := utils.ContainerToKey(host, containerId) @@ -173,7 +182,7 @@ func redisValueToUint32(value interface{}) (uint32, error) { func FreePortOnHost(host string, port uint32) error { if Cache == nil { - Init() + return fmt.Errorf("redis cache not initialized") } CacheMutex.Lock() @@ -193,7 +202,7 @@ func FreePortOnHost(host string, port uint32) error { // FreeContainerPortsOnHost frees all allocated host ports on a machine, at present occupied by a container func FreeContainerPortsOnHost(host string, containerId string) error { if Cache == nil { - Init() + return fmt.Errorf("redis cache not initialized") } CacheMutex.Lock() @@ -203,28 +212,12 @@ func FreeContainerPortsOnHost(host string, containerId string) error { hostKey := utils.HostToKey(host) instanceKey := utils.ContainerToKey(host, containerId) - result, err := Cache.SMembers(ctx, instanceKey).Result() + result, err := Cache.Eval(ctx, freeContainerPortsScript, []string{hostKey, instanceKey}).Result() if err != nil { return err } - - ports := make([]uint32, len(result)) - for i, portString := range result { - port, err := strconv.ParseUint(portString, 10, 32) - if err != nil { - return err - } - - ports[i] = uint32(port) - Cache.SRem(ctx, instanceKey, port) - } - - for _, port := range ports { - _, err = Cache.SRem(ctx, hostKey, port).Result() - if err != nil { - return err - } + if _, ok := result.([]interface{}); !ok { + return fmt.Errorf("unexpected Redis free-port result %T", result) } - return nil } diff --git a/core/cache/ports_instance_test.go b/core/cache/ports_instance_test.go index 78da801a..9700860a 100644 --- a/core/cache/ports_instance_test.go +++ b/core/cache/ports_instance_test.go @@ -34,7 +34,7 @@ func setupRedisIntegrationTest(t *testing.T) func() { previousMutex := CacheMutex previousConfig := cacheConfig - CacheMutex = &sync.Mutex{} + CacheMutex = &sync.RWMutex{} Cache = redis.NewClient(&redis.Options{ Addr: addr, Username: os.Getenv("BEAST_TEST_REDIS_USER"), From f78f66ed6fe38e4d9e556acc906f20ba26714197 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 15:35:03 +0530 Subject: [PATCH 128/149] fix(compose): extract build contexts safely Signed-off-by: vibhatsu --- pkg/cr/images.go | 15 +++-- pkg/remoteManager/file.go | 7 ++- utils/archive.go | 113 ++++++++++++++++++++++++++++++++++++-- utils/archive_test.go | 77 ++++++++++++++++++++++++++ 4 files changed, 199 insertions(+), 13 deletions(-) diff --git a/pkg/cr/images.go b/pkg/cr/images.go index ad69337e..290f4496 100644 --- a/pkg/cr/images.go +++ b/pkg/cr/images.go @@ -198,12 +198,15 @@ func BuildImageFromTarContext(challengeName, challengeTag, tarContextPath, docke func BuildImagesFromCompose(challengeName, challengeTag, stagedPath, ComposeFile string, noCache bool) (*bytes.Buffer, error) { extractPath := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_STAGING_DIR, challengeName, challengeName) - if err := os.MkdirAll(extractPath, 0750); err != nil { - return nil, fmt.Errorf("create compose extraction directory %s: %w", extractPath, err) - } - err := exec.Command("tar", "-xf", stagedPath, "-C", extractPath).Run() - if err != nil { - return nil, fmt.Errorf("error while extracting tar file %s to %s: %v", stagedPath, extractPath, err) + if _, err := os.Lstat(extractPath); err == nil { + if err := os.RemoveAll(extractPath); err != nil { + return nil, fmt.Errorf("clear compose extraction directory %s: %w", extractPath, err) + } + } else if !os.IsNotExist(err) { + return nil, fmt.Errorf("inspect compose extraction directory %s: %w", extractPath, err) + } + if err := utils.ExtractTarGzip(stagedPath, extractPath); err != nil { + return nil, fmt.Errorf("extract compose context: %w", err) } cmdArgs := []string{"compose", "build"} diff --git a/pkg/remoteManager/file.go b/pkg/remoteManager/file.go index e1bef935..2e1112ca 100644 --- a/pkg/remoteManager/file.go +++ b/pkg/remoteManager/file.go @@ -78,9 +78,12 @@ func BuildImageFromTarContextRemote(challengeName string, imageTag string, stage return nil, "", fmt.Errorf("invalid build resource limits: %w", err) } remoteExtractPath := filepath.Join(core.BEAST_REMOTE_GLOBAL_DIR, core.BEAST_STAGING_DIR, challengeName, challengeName) - _, err := RunArgsOnServer(server, "mkdir", "-p", remoteExtractPath) + _, err := RunArgsOnServer(server, "rm", "-rf", "--", remoteExtractPath) if err == nil { - _, err = RunArgsOnServer(server, "tar", "-xf", stagedDir, "-C", remoteExtractPath) + _, err = RunArgsOnServer(server, "mkdir", "-p", "--", remoteExtractPath) + } + if err == nil { + _, err = RunArgsOnServer(server, "tar", "--extract", "--gzip", "--no-same-owner", "--no-same-permissions", "--file", stagedDir, "--directory", remoteExtractPath) } if err != nil { return []byte{}, "", fmt.Errorf("failed to extract tar: %s", err) diff --git a/utils/archive.go b/utils/archive.go index 98c327fc..ca270841 100644 --- a/utils/archive.go +++ b/utils/archive.go @@ -16,7 +16,9 @@ import ( type Compression int const ( - Gzip Compression = 1 + Gzip Compression = 1 + maxExtractedArchiveFiles = 10000 + maxExtractedArchiveBytes = int64(1 << 30) ) // Tar the provided context directory into the destination directory, additionalCtx is the context @@ -75,7 +77,6 @@ func Tar(contextDir string, compression Compression, destinationDir string, addi return false } - baseDir := filepath.Base(contextDir) err = filepath.Walk(contextDir, func(path string, info os.FileInfo, err error) error { if err != nil { @@ -95,9 +96,11 @@ func Tar(contextDir string, compression Compression, destinationDir string, addi return err } - if baseDir != "" { - header.Name = filepath.Join(strings.TrimPrefix(path, contextDir)) + relativePath, err := filepath.Rel(contextDir, path) + if err != nil { + return fmt.Errorf("resolve archive path: %w", err) } + header.Name = filepath.ToSlash(relativePath) if err := tarFileWriter.WriteHeader(header); err != nil { return err @@ -129,7 +132,11 @@ func Tar(contextDir string, compression Compression, destinationDir string, addi return fmt.Errorf("create archive header for %s: %w", filePath, err) } - header.Name = filepath.Join(fileName) + cleanName := filepath.Clean(fileName) + if cleanName == "." || filepath.IsAbs(cleanName) || cleanName == ".." || strings.HasPrefix(cleanName, ".."+string(filepath.Separator)) { + return fmt.Errorf("invalid additional archive name %q", fileName) + } + header.Name = filepath.ToSlash(cleanName) if err := tarFileWriter.WriteHeader(header); err != nil { return fmt.Errorf("write archive header for %s: %w", filePath, err) @@ -174,3 +181,99 @@ func Tar(contextDir string, compression Compression, destinationDir string, addi return nil } + +// ExtractTarGzip extracts a bounded archive into a new directory without +// following links or accepting paths outside that directory. +func ExtractTarGzip(archivePath, destination string) error { + if _, err := os.Lstat(destination); err == nil { + return fmt.Errorf("archive destination already exists: %s", destination) + } else if !os.IsNotExist(err) { + return fmt.Errorf("inspect archive destination: %w", err) + } + if err := os.Mkdir(destination, 0700); err != nil { + return fmt.Errorf("create archive destination: %w", err) + } + complete := false + defer func() { + if !complete { + _ = os.RemoveAll(destination) + } + }() + + archiveFile, err := os.Open(archivePath) + if err != nil { + return fmt.Errorf("open archive: %w", err) + } + defer archiveFile.Close() + gzipReader, err := gzip.NewReader(archiveFile) + if err != nil { + return fmt.Errorf("open gzip stream: %w", err) + } + defer gzipReader.Close() + + reader := tar.NewReader(gzipReader) + seen := make(map[string]struct{}) + var totalSize int64 + for count := 0; ; count++ { + header, err := reader.Next() + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("read archive: %w", err) + } + if count >= maxExtractedArchiveFiles { + return fmt.Errorf("archive contains more than %d entries", maxExtractedArchiveFiles) + } + name := filepath.Clean(filepath.FromSlash(header.Name)) + if filepath.IsAbs(name) || name == ".." || strings.HasPrefix(name, ".."+string(filepath.Separator)) { + return fmt.Errorf("archive path escapes destination: %q", header.Name) + } + if _, exists := seen[name]; exists { + return fmt.Errorf("archive contains duplicate path %q", header.Name) + } + seen[name] = struct{}{} + if header.Size < 0 || header.Size > maxExtractedArchiveBytes-totalSize { + return fmt.Errorf("archive expands beyond %d bytes", maxExtractedArchiveBytes) + } + totalSize += header.Size + + target := filepath.Join(destination, name) + relative, err := filepath.Rel(destination, target) + if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return fmt.Errorf("archive path escapes destination: %q", header.Name) + } + switch header.Typeflag { + case tar.TypeDir: + if name == "." { + continue + } + if err := os.Mkdir(target, header.FileInfo().Mode().Perm()); err != nil { + return fmt.Errorf("create archive directory %q: %w", header.Name, err) + } + case tar.TypeReg, tar.TypeRegA: + if err := os.MkdirAll(filepath.Dir(target), 0700); err != nil { + return fmt.Errorf("create archive parent: %w", err) + } + file, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_EXCL, header.FileInfo().Mode().Perm()) + if err != nil { + return fmt.Errorf("create archive file %q: %w", header.Name, err) + } + written, copyErr := io.Copy(file, io.LimitReader(reader, header.Size+1)) + closeErr := file.Close() + if copyErr != nil { + return fmt.Errorf("extract archive file %q: %w", header.Name, copyErr) + } + if closeErr != nil { + return fmt.Errorf("close archive file %q: %w", header.Name, closeErr) + } + if written != header.Size { + return fmt.Errorf("archive file %q size mismatch", header.Name) + } + default: + return fmt.Errorf("archive contains unsupported entry %q", header.Name) + } + } + complete = true + return nil +} diff --git a/utils/archive_test.go b/utils/archive_test.go index 475cc6bf..ab8272f5 100644 --- a/utils/archive_test.go +++ b/utils/archive_test.go @@ -1,12 +1,55 @@ package utils import ( + "archive/tar" + "compress/gzip" + "io" "os" "path/filepath" "strings" "testing" ) +func TestTarUsesRelativeEntryNames(t *testing.T) { + parent := t.TempDir() + contextDir := filepath.Join(parent, "challenge") + destinationDir := filepath.Join(parent, "staging") + if err := os.Mkdir(contextDir, 0700); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(destinationDir, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(contextDir, "file.txt"), []byte("test"), 0600); err != nil { + t.Fatal(err) + } + if err := Tar(contextDir, Gzip, destinationDir, nil, nil); err != nil { + t.Fatal(err) + } + file, err := os.Open(filepath.Join(destinationDir, "challenge.tar.gz")) + if err != nil { + t.Fatal(err) + } + defer file.Close() + gzipReader, err := gzip.NewReader(file) + if err != nil { + t.Fatal(err) + } + reader := tar.NewReader(gzipReader) + for { + header, err := reader.Next() + if err == io.EOF { + break + } + if err != nil { + t.Fatal(err) + } + if filepath.IsAbs(header.Name) || strings.HasPrefix(header.Name, "../") { + t.Fatalf("archive contains unsafe entry name %q", header.Name) + } + } +} + func TestTarRejectsSymlinks(t *testing.T) { parent := t.TempDir() contextDir := filepath.Join(parent, "challenge") @@ -55,3 +98,37 @@ func TestTarFailsWhenAdditionalContextIsMissing(t *testing.T) { t.Fatalf("partial archive was not removed: %v", statErr) } } + +func TestExtractTarGzipRejectsTraversal(t *testing.T) { + archivePath := filepath.Join(t.TempDir(), "unsafe.tar.gz") + file, err := os.Create(archivePath) + if err != nil { + t.Fatal(err) + } + gzipWriter := gzip.NewWriter(file) + tarWriter := tar.NewWriter(gzipWriter) + contents := []byte("secret") + if err := tarWriter.WriteHeader(&tar.Header{Name: "../escape", Mode: 0600, Size: int64(len(contents))}); err != nil { + t.Fatal(err) + } + if _, err := tarWriter.Write(contents); err != nil { + t.Fatal(err) + } + if err := tarWriter.Close(); err != nil { + t.Fatal(err) + } + if err := gzipWriter.Close(); err != nil { + t.Fatal(err) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } + + destination := filepath.Join(t.TempDir(), "extracted") + if err := ExtractTarGzip(archivePath, destination); err == nil { + t.Fatal("expected traversal rejection") + } + if _, err := os.Lstat(destination); !os.IsNotExist(err) { + t.Fatalf("partial extraction was not removed: %v", err) + } +} From 733b977b134b3f6ba7047c90ebe85518ba24df7d Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 15:41:10 +0530 Subject: [PATCH 129/149] fix(compose): reject unknown runtime controls Signed-off-by: vibhatsu --- .../instanced-compose/docker-compose.yml | 2 -- utils/compose.go | 25 +++++++++++++++++-- utils/compose_test.go | 25 +++++++++++++++++++ 3 files changed, 48 insertions(+), 4 deletions(-) diff --git a/_examples/instanced-compose/docker-compose.yml b/_examples/instanced-compose/docker-compose.yml index 684a0e63..e3891316 100644 --- a/_examples/instanced-compose/docker-compose.yml +++ b/_examples/instanced-compose/docker-compose.yml @@ -20,7 +20,6 @@ services: - DB_NAME=ctf depends_on: - db - restart: unless-stopped db: image: mysql:5.7 @@ -37,4 +36,3 @@ services: - MYSQL_PASSWORD=challengepass volumes: - ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro - restart: unless-stopped diff --git a/utils/compose.go b/utils/compose.go index a7c9344e..637980b2 100644 --- a/utils/compose.go +++ b/utils/compose.go @@ -15,6 +15,14 @@ import ( type composeService struct { Build interface{} `yaml:"build"` + Image string `yaml:"image"` + Command interface{} `yaml:"command"` + Entrypoint interface{} `yaml:"entrypoint"` + Environment interface{} `yaml:"environment"` + DependsOn interface{} `yaml:"depends_on"` + Healthcheck interface{} `yaml:"healthcheck"` + ReadOnly bool `yaml:"read_only"` + Restart string `yaml:"restart"` Ports []string `yaml:"ports"` Privileged bool `yaml:"privileged"` NetworkMode string `yaml:"network_mode"` @@ -51,7 +59,7 @@ func ValidateComposeResources(composeFile string, maxMemory, maxPids int64, maxC return err } var compose Compose - if err := yaml.Unmarshal(data, &compose); err != nil { + if err := yaml.UnmarshalStrict(data, &compose); err != nil { return err } var totalMemory, totalPids int64 @@ -101,6 +109,7 @@ type composeResource struct { } type Compose struct { + Version string `yaml:"version"` Services map[string]composeService `yaml:"services"` Volumes map[string]composeResource `yaml:"volumes"` Networks map[string]composeResource `yaml:"networks"` @@ -276,6 +285,18 @@ func validateComposeSecurity(composeFile string, compose Compose) error { if service.Privileged { return fmt.Errorf("service %q cannot be privileged", name) } + if service.Restart != "" && service.Restart != "no" { + return fmt.Errorf("service %q cannot set restart policy %q", name, service.Restart) + } + withoutPorts := service + withoutPorts.Ports = nil + encodedService, err := yaml.Marshal(withoutPorts) + if err != nil { + return fmt.Errorf("inspect service %q interpolation: %w", name, err) + } + if strings.Contains(string(encodedService), "${") { + return fmt.Errorf("service %q can use variable interpolation only for managed ports", name) + } if service.NetworkMode != "" && service.NetworkMode != "bridge" && service.NetworkMode != "none" { return fmt.Errorf("service %q uses forbidden network_mode %q", name, service.NetworkMode) } @@ -343,7 +364,7 @@ func ExtractPortsFromCompose(composeFile string) ([]string, error) { return nil, fmt.Errorf("error while reading compose file: %w", err) } var raw Compose - err = yaml.Unmarshal(data, &raw) + err = yaml.UnmarshalStrict(data, &raw) if err != nil { return nil, fmt.Errorf("error while parsing compose file: %s", err.Error()) } diff --git a/utils/compose_test.go b/utils/compose_test.go index a3c5703e..b0fef8ca 100644 --- a/utils/compose_test.go +++ b/utils/compose_test.go @@ -108,6 +108,20 @@ func TestExtractPortsFromComposeRequiresRuntimeHardening(t *testing.T) { } } +func TestExtractPortsFromComposeRejectsUnknownSecurityFields(t *testing.T) { + path := writeCompose(t, "services:\n app:\n cap_drop: [ALL]\n security_opt: [no-new-privileges:true]\n use_api_socket: true\n ports: [\"${APP_PORT}:8080\"]\n") + if _, err := ExtractPortsFromCompose(path); err == nil { + t.Fatal("expected unknown Compose field rejection") + } +} + +func TestExtractPortsFromComposeRejectsHostInterpolation(t *testing.T) { + path := writeCompose(t, "services:\n app:\n cap_drop: [ALL]\n security_opt: [no-new-privileges:true]\n environment: [\"TOKEN=${HOST_TOKEN}\"]\n ports: [\"${APP_PORT}:8080\"]\n") + if _, err := ExtractPortsFromCompose(path); err == nil || !strings.Contains(err.Error(), "interpolation") { + t.Fatalf("expected host interpolation rejection, got %v", err) + } +} + func TestValidateComposeResourcesEnforcesAggregateLimit(t *testing.T) { path := writeCompose(t, "services:\n app:\n mem_limit: 256m\n cpus: 0.25\n pids_limit: 50\n db:\n mem_limit: 256m\n cpus: 0.25\n pids_limit: 50\n") if err := ValidateComposeResources(path, 512<<20, 100, 0.5); err != nil { @@ -117,3 +131,14 @@ func TestValidateComposeResourcesEnforcesAggregateLimit(t *testing.T) { t.Fatal("expected aggregate memory limit error") } } + +func TestExampleComposeFilesPassSecurityValidation(t *testing.T) { + for _, path := range []string{ + filepath.Join("..", "_examples", "compose-type", "docker-compose.yml"), + filepath.Join("..", "_examples", "instanced-compose", "docker-compose.yml"), + } { + if _, err := ExtractPortsFromCompose(path); err != nil { + t.Fatalf("example %s failed security validation: %v", path, err) + } + } +} From 39e20f1b40216872422bcce11356a000d5c7dc64 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 15:42:40 +0530 Subject: [PATCH 130/149] fix(remote): fail startup on unavailable workers Signed-off-by: vibhatsu --- api/main.go | 8 +++++++- cmd/beast/runtime.go | 8 +++++++- pkg/remoteManager/init.go | 18 +++++++++++------- 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/api/main.go b/api/main.go index 6a5b2296..ca844533 100644 --- a/api/main.go +++ b/api/main.go @@ -109,9 +109,15 @@ func RunBeastApiServer(ctx context.Context, port, defaultauthorpassword string, } return err } + if err := remoteManager.Init(); err != nil { + _ = cache.Close() + if sqlDB, dbErr := database.Db.DB(); dbErr == nil { + _ = sqlDB.Close() + } + return err + } manager.Q = wpool.InitQueue(core.MAX_QUEUE_SIZE, nil) manager.Q.StartWorkers(&manager.Worker{}) - remoteManager.Init() backgroundCtx, stopBackground := context.WithCancel(ctx) dynamicScoreDone := startDynamicScoreWorker(backgroundCtx) instanceCleanupDone := make(chan struct{}) diff --git a/cmd/beast/runtime.go b/cmd/beast/runtime.go index 3adccad7..f82ac7cf 100644 --- a/cmd/beast/runtime.go +++ b/cmd/beast/runtime.go @@ -31,7 +31,13 @@ func initializeCLIRuntime(requireCache, requireRemotes bool) (func(), error) { cacheInitialized = true } if requireRemotes { - remoteManager.Init() + if err := remoteManager.Init(); err != nil { + if cacheInitialized { + _ = cache.Close() + } + closeCLIDatabase() + return nil, err + } } return func() { diff --git a/pkg/remoteManager/init.go b/pkg/remoteManager/init.go index cf94bfc6..f9724caa 100644 --- a/pkg/remoteManager/init.go +++ b/pkg/remoteManager/init.go @@ -1,15 +1,17 @@ package remoteManager import ( + "errors" + "fmt" "path/filepath" "github.com/sdslabs/beastv4/core" "github.com/sdslabs/beastv4/core/config" - log "github.com/sirupsen/logrus" ) -func Init() { +func Init() error { ServerQueue = NewLoadBalancerQueue() + var failures []error for serverDeployed, server := range config.Cfg.AvailableServers { if server.Active { // Skip SSH bootstrap for loopback workers; they use the local Docker socket from Beast. @@ -18,17 +20,19 @@ func Init() { } client, err := CreateSSHClient(server) if err != nil { - log.Errorf("SSH connection to %s failed: %s\n", server.Host, err) + failures = append(failures, fmt.Errorf("connect to remote %s: %w", serverDeployed, err)) continue } - defer client.Close() - ServerQueue.Push(server) - _, err = RunArgsOnServer(server, "mkdir", "-p", filepath.Join(core.BEAST_REMOTE_GLOBAL_DIR, core.BEAST_STAGING_DIR)) + _ = client.Close() + _, err = RunArgsOnServer(server, "mkdir", "-p", "--", filepath.Join(core.BEAST_REMOTE_GLOBAL_DIR, core.BEAST_STAGING_DIR)) if err != nil { - log.Errorf("failed to run command on server %s: %s", server.Host, err.Error()) + failures = append(failures, fmt.Errorf("prepare remote %s: %w", serverDeployed, err)) + continue } + ServerQueue.Push(server) } } + return errors.Join(failures...) } func Stop() { From 03dded99b866855d9063eeac609c3c20d96aec0a Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 15:45:59 +0530 Subject: [PATCH 131/149] fix(scripts): verify teardown ownership Signed-off-by: vibhatsu --- scripts/teardown.sh | 19 +- scripts/test/backend_submit_race.sh | 349 +--------------------------- 2 files changed, 27 insertions(+), 341 deletions(-) diff --git a/scripts/teardown.sh b/scripts/teardown.sh index 8dc3d32a..e2d17a3a 100755 --- a/scripts/teardown.sh +++ b/scripts/teardown.sh @@ -13,13 +13,28 @@ beast_dir=${BEAST_HOME:-"${HOME}/.beast"} lock_file="${beast_dir}/controller.lock" if [[ -f "${lock_file}" ]]; then - read -r controller_pid <"${lock_file}" || true - if [[ "${controller_pid:-}" =~ ^[0-9]+$ ]] && kill -0 "${controller_pid}" 2>/dev/null; then + if ! command -v flock >/dev/null 2>&1; then + echo "flock is required to verify the Beast controller lock" >&2 + exit 1 + fi + exec 9<>"${lock_file}" + if flock --nonblock 9; then + flock --unlock 9 + else + read -r controller_pid <&9 || true + if [[ ! "${controller_pid:-}" =~ ^[0-9]+$ ]] || ! kill -0 "${controller_pid}" 2>/dev/null; then + echo "controller lock is held but its PID is invalid; data was not removed" >&2 + exit 1 + fi executable=$(readlink -f "/proc/${controller_pid}/exe" 2>/dev/null || true) if [[ $(basename -- "${executable}") != beast ]]; then echo "refusing to signal PID ${controller_pid}: it is not a Beast process" >&2 exit 1 fi + if [[ $(stat -c '%u' "/proc/${controller_pid}" 2>/dev/null || true) != "$(id -u)" ]]; then + echo "refusing to signal PID ${controller_pid}: it belongs to another user" >&2 + exit 1 + fi kill -TERM "${controller_pid}" for _ in {1..60}; do if ! kill -0 "${controller_pid}" 2>/dev/null; then diff --git a/scripts/test/backend_submit_race.sh b/scripts/test/backend_submit_race.sh index abcb7eb8..edb213fc 100755 --- a/scripts/test/backend_submit_race.sh +++ b/scripts/test/backend_submit_race.sh @@ -1,345 +1,16 @@ #!/usr/bin/env bash -set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" - -PGHOST="${BEAST_TEST_PGHOST:-localhost}" -PGPORT="${BEAST_TEST_PGPORT:-55543}" -PGUSER="${BEAST_TEST_PGUSER:-beasttest}" -PGPASSWORD="${BEAST_TEST_PGPASSWORD:-beasttest}" -PGDATABASE="${BEAST_TEST_PGDATABASE:-beast_backend_test}" -REDIS_HOST="${BEAST_TEST_REDIS_HOST:-localhost}" -REDIS_PORT="${BEAST_TEST_REDIS_PORT:-56380}" -SERVER_PORT="${BEAST_TEST_SERVER_PORT:-5505}" -TEST_HOME="${BEAST_TEST_HOME:-/tmp/beast-backend-submit-race}" -LOG_FILE="$TEST_HOME/beast-api.log" -BASE_URL="http://localhost:$SERVER_PORT" - -export PGPASSWORD - -cleanup() { - local status=$? - if [[ -n "${SERVER_PID:-}" ]] && kill -0 "$SERVER_PID" 2>/dev/null; then - kill "$SERVER_PID" 2>/dev/null || true - wait "$SERVER_PID" 2>/dev/null || true - fi - exit "$status" -} -trap cleanup EXIT - -psql_root() { - PGPASSWORD="$PGPASSWORD" psql -q -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d postgres "$@" -} - -psql_test() { - PGPASSWORD="$PGPASSWORD" psql -q -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" "$@" -} - -wait_for_http() { - for _ in $(seq 1 60); do - if curl -fsS "$BASE_URL/" >/dev/null 2>&1; then - return 0 - fi - sleep 1 - done - - echo "backend did not become ready; last log lines:" >&2 - tail -120 "$LOG_FILE" >&2 || true - return 1 -} - -json_field() { - jq -r "$1" -} - -register_user() { - local username="$1" - local password="$2" - curl -fsS -X POST "$BASE_URL/auth/register" \ - -F "name=$username" \ - -F "username=$username" \ - -F "password=$password" \ - -F "email=$username@example.test" >/dev/null -} - -login_user() { - local username="$1" - local password="$2" - curl -fsS -X POST "$BASE_URL/auth/login" \ - -F "username=$username" \ - -F "password=$password" | json_field '.token' -} - -seed_challenge() { - local name="$1" - local flag="$2" - local max_attempts="$3" - local dynamic="$4" - local author_id - author_id="$(psql_test -Atc "SELECT id FROM users ORDER BY id LIMIT 1")" - - psql_test -Atc " - INSERT INTO challenges ( - created_at, - updated_at, - name, - dynamic_flag, - flag, - type, - difficulty, - max_attempt_limit, - format, - container_id, - image_id, - status, - deployment_type, - author_id, - health_check, - points, - max_points, - min_points, - server_deployed - ) - VALUES ( - now(), - now(), - '$name', - $dynamic, - '$flag', - 'web', - 'easy', - $max_attempts, - 'web', - 'container-$name', - 'image-$name', - 'Deployed', - 'standard_docker', - $author_id, - 0, - 500, - 500, - 100, - 'localhost' - ) - RETURNING id" -} - -submit_concurrently() { - local token="$1" - local challenge_id="$2" - local flag="$3" - local requests="$4" - - python3 - "$BASE_URL" "$token" "$challenge_id" "$flag" "$requests" <<'PY' -import concurrent.futures -import json -import sys -import urllib.error -import urllib.parse -import urllib.request - -base_url, token, challenge_id, flag, requests = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4], int(sys.argv[5]) - -def submit(_): - data = urllib.parse.urlencode({"chall_id": challenge_id, "flag": flag}).encode() - request = urllib.request.Request( - base_url + "/api/submit/challenge", - data=data, - headers={"Authorization": "Bearer " + token}, - method="POST", - ) - try: - with urllib.request.urlopen(request, timeout=10) as response: - body = response.read().decode() - return response.status, json.loads(body) - except urllib.error.HTTPError as error: - body = error.read().decode() - try: - parsed = json.loads(body) - except json.JSONDecodeError: - parsed = {"raw": body} - return error.code, parsed - -with concurrent.futures.ThreadPoolExecutor(max_workers=min(32, requests)) as executor: - results = list(executor.map(submit, range(requests))) - -print(json.dumps(results)) -PY -} - -assert_one_success() { - local results_json="$1" - local successes - successes="$(jq '[.[] | select(.[1].success == true)] | length' <<<"$results_json")" - if [[ "$successes" != "1" ]]; then - echo "expected exactly one successful submit response, got $successes" >&2 - jq . <<<"$results_json" >&2 - return 1 - fi -} - -assert_zero_successes() { - local results_json="$1" - local successes - successes="$(jq '[.[] | select(.[1].success == true)] | length' <<<"$results_json")" - if [[ "$successes" != "0" ]]; then - echo "expected zero successful submit responses, got $successes" >&2 - jq . <<<"$results_json" >&2 - return 1 - fi -} - -rm -rf "$TEST_HOME" -mkdir -p "$TEST_HOME/.beast/scripts" "$TEST_HOME/.beast/cache" "$TEST_HOME/.beast/remotes" "$TEST_HOME/.beast/uploads" "$TEST_HOME/.beast/secrets" "$TEST_HOME/.beast/staging" "$TEST_HOME/.beast/assets/logo" - -psql_root -v ON_ERROR_STOP=1 -c "DROP DATABASE IF EXISTS $PGDATABASE WITH (FORCE)" >/dev/null -psql_root -v ON_ERROR_STOP=1 -c "CREATE DATABASE $PGDATABASE" >/dev/null -cat >"$TEST_HOME/.beast/config.toml" <"$LOG_FILE" 2>&1 -) & -SERVER_PID=$! - -wait_for_http - -register_user "apiwinner" "pw" -TOKEN_WINNER="$(login_user "apiwinner" "pw")" -CHALLENGE_CORRECT_ID="$(seed_challenge "api-race-correct" "flag{api-correct}" -1 false)" -CORRECT_RESULTS="$(submit_concurrently "$TOKEN_WINNER" "$CHALLENGE_CORRECT_ID" "flag{api-correct}" 64)" -assert_one_success "$CORRECT_RESULTS" - -WINNER_SCORE="$(psql_test -Atc "SELECT score FROM users WHERE username = 'apiwinner'")" -if [[ "$WINNER_SCORE" != "500" ]]; then - echo "expected apiwinner score 500, got $WINNER_SCORE" >&2 - exit 1 -fi -SOLVED_ROWS="$(psql_test -Atc "SELECT COUNT(*) FROM user_challenges WHERE user_id = (SELECT id FROM users WHERE username = 'apiwinner') AND challenge_id = $CHALLENGE_CORRECT_ID AND solved = true")" -if [[ "$SOLVED_ROWS" != "1" ]]; then - echo "expected one solved user_challenges row, got $SOLVED_ROWS" >&2 - exit 1 -fi - -register_user "apiwrong" "pw" -TOKEN_WRONG="$(login_user "apiwrong" "pw")" -CHALLENGE_WRONG_ID="$(seed_challenge "api-race-wrong" "flag{api-wrong}" 3 false)" -WRONG_RESULTS="$(submit_concurrently "$TOKEN_WRONG" "$CHALLENGE_WRONG_ID" "not-the-flag" 64)" -assert_zero_successes "$WRONG_RESULTS" - -WRONG_TRIES="$(psql_test -Atc "SELECT tries FROM user_challenges WHERE user_id = (SELECT id FROM users WHERE username = 'apiwrong') AND challenge_id = $CHALLENGE_WRONG_ID")" -if [[ "$WRONG_TRIES" != "3" ]]; then - echo "expected apiwrong tries 3, got $WRONG_TRIES" >&2 - exit 1 -fi -WRONG_SCORE="$(psql_test -Atc "SELECT score FROM users WHERE username = 'apiwrong'")" -if [[ "$WRONG_SCORE" != "0" ]]; then - echo "expected apiwrong score 0, got $WRONG_SCORE" >&2 - exit 1 -fi - -register_user "apidynone" "pw" -register_user "apidyntwo" "pw" -TOKEN_DYN_ONE="$(login_user "apidynone" "pw")" -TOKEN_DYN_TWO="$(login_user "apidyntwo" "pw")" -CHALLENGE_DYNAMIC_ID="$(seed_challenge "api-race-dynamic" "unused-static-flag" -1 true)" -psql_test -v ON_ERROR_STOP=1 -c "INSERT INTO dynamic_flags (created_at, updated_at, name, flag) VALUES (now(), now(), 'api-race-dynamic', 'flag{dynamic-shared}')" >/dev/null - -DYNAMIC_RESULTS="$( - python3 - "$BASE_URL" "$TOKEN_DYN_ONE" "$TOKEN_DYN_TWO" "$CHALLENGE_DYNAMIC_ID" <<'PY' -import concurrent.futures -import json -import sys -import urllib.parse -import urllib.request - -base_url, token_one, token_two, challenge_id = sys.argv[1:5] - -def submit(token): - data = urllib.parse.urlencode({"chall_id": challenge_id, "flag": "flag{dynamic-shared}"}).encode() - request = urllib.request.Request( - base_url + "/api/submit/challenge", - data=data, - headers={"Authorization": "Bearer " + token}, - method="POST", - ) - with urllib.request.urlopen(request, timeout=10) as response: - return response.status, json.loads(response.read().decode()) - -with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor: - results = list(executor.map(submit, [token_one, token_two])) -print(json.dumps(results)) -PY -)" -assert_one_success "$DYNAMIC_RESULTS" +set -euo pipefail -DYNAMIC_CLAIMS="$(psql_test -Atc "SELECT COUNT(*) FROM dynamic_flag_claims WHERE challenge_id = $CHALLENGE_DYNAMIC_ID AND flag = 'flag{dynamic-shared}'")" -if [[ "$DYNAMIC_CLAIMS" != "1" ]]; then - echo "expected one dynamic flag claim, got $DYNAMIC_CLAIMS" >&2 - exit 1 +if [[ -z "${BEAST_TEST_PG_DSN:-}" ]]; then + echo "set BEAST_TEST_PG_DSN to an isolated PostgreSQL database" >&2 + echo "the test creates and drops temporary schemas" >&2 + exit 2 fi -LEADERBOARD="$(curl -fsS -H "Authorization: Bearer $TOKEN_WINNER" "$BASE_URL/api/info/leaderboard?page=1")" -if ! jq -e '.[] | select(.username == "apiwinner" and .score == 500)' <<<"$LEADERBOARD" >/dev/null; then - echo "leaderboard did not include apiwinner score 500" >&2 - jq . <<<"$LEADERBOARD" >&2 - exit 1 -fi +repo_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) +cd "${repo_dir}" -echo "backend submit race verification passed" +go test -race ./core/database \ + -run 'TestConcurrentCorrectSubmissionsAwardOnce|TestConcurrentWrongSubmissionsRespectMaxAttempts|TestDynamicFlagClaimFirstClaimWins|TestDynamicScoreDirtyCoalescesConcurrentMarks' \ + -count=1 From 2f5eb526b9e44ae9bd9afdcf5fa42cd9dafa49f1 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 15:56:36 +0530 Subject: [PATCH 132/149] fix(database): preserve challenge relations safely Signed-off-by: vibhatsu --- core/database/challenge_update.go | 2 +- core/database/challenges.go | 2 +- core/database/database.go | 3 + core/database/ports.go | 97 +++++++++++++++++----------- core/database/relations_test.go | 101 ++++++++++++++++++++++++++++++ core/database/submission_test.go | 7 ++- core/database/tag.go | 40 +++++------- core/database/user.go | 2 +- core/manager/utils.go | 1 + 9 files changed, 190 insertions(+), 65 deletions(-) create mode 100644 core/database/relations_test.go diff --git a/core/database/challenge_update.go b/core/database/challenge_update.go index 43653ce8..220abe81 100644 --- a/core/database/challenge_update.go +++ b/core/database/challenge_update.go @@ -29,7 +29,7 @@ func UpdateChallengeConfiguration(challengeID uint, updates map[string]interface return err } for _, portNumber := range *ports { - if err := tx.Create(&Port{ChallengeID: challengeID, PortNo: portNumber}).Error; err != nil { + if err := tx.Create(&Port{ChallengeID: challengeID, Server: challenge.ServerDeployed, PortNo: portNumber}).Error; err != nil { return fmt.Errorf("reserve port %d: %w", portNumber, err) } } diff --git a/core/database/challenges.go b/core/database/challenges.go index 7884aa78..36c43ae8 100644 --- a/core/database/challenges.go +++ b/core/database/challenges.go @@ -402,7 +402,7 @@ func GetRelatedTags(challenge *Challenge) ([]Tag, error) { DBMux.RLock() defer DBMux.RUnlock() - if err := Db.Model(challenge).Association("Tags").Error; err != nil { + if err := Db.Model(challenge).Association("Tags").Find(&tags); err != nil { return tags, err } diff --git a/core/database/database.go b/core/database/database.go index a8b76369..17ff0aab 100644 --- a/core/database/database.go +++ b/core/database/database.go @@ -113,6 +113,9 @@ func Init() error { if err := MigrateSubmissionGuards(); err != nil { return fmt.Errorf("migrate submission guards: %w", err) } + if err := MigratePortUniqueness(); err != nil { + return fmt.Errorf("migrate port uniqueness: %w", err) + } if err := MigrateChallengeMaintainers(); err != nil { return fmt.Errorf("migrate challenge maintainers: %w", err) } diff --git a/core/database/ports.go b/core/database/ports.go index bb50f903..4c5330d8 100644 --- a/core/database/ports.go +++ b/core/database/ports.go @@ -1,16 +1,18 @@ package database import ( + "errors" "fmt" - "github.com/jinzhu/gorm" + "gorm.io/gorm" ) type Port struct { gorm.Model ChallengeID uint `gorm:"not null"` - PortNo uint32 `gorm:"not null;unique"` + Server string `gorm:"not null;default:'';uniqueIndex:idx_ports_server_port"` + PortNo uint32 `gorm:"not null;uniqueIndex:idx_ports_server_port"` } // Create an entry for the port in the Port table @@ -18,22 +20,23 @@ type Port struct { // transaction. If the entry already exists then it does not // do anything and returns. func PortEntryGetOrCreate(port *Port) (Port, error) { + if port == nil || port.ChallengeID == 0 { + return Port{}, errors.New("persisted challenge port is required") + } DBMux.Lock() defer DBMux.Unlock() - tx := Db.Begin() - - if tx.Error != nil { - return Port{}, fmt.Errorf("Error while starting transaction : %s", tx.Error) - } - - err := tx.FirstOrCreate(port, *port).Error - if err != nil { - tx.Rollback() - return Port{}, err - } - - return *port, tx.Commit().Error + err := Db.Transaction(func(tx *gorm.DB) error { + if port.Server == "" { + var challenge Challenge + if err := tx.Select("server_deployed").First(&challenge, port.ChallengeID).Error; err != nil { + return fmt.Errorf("resolve challenge server: %w", err) + } + port.Server = challenge.ServerDeployed + } + return tx.FirstOrCreate(port, Port{Server: port.Server, PortNo: port.PortNo}).Error + }) + return *port, err } func GetAllocatedPorts(challenge Challenge) ([]Port, error) { @@ -50,40 +53,58 @@ func GetAllocatedPorts(challenge Challenge) ([]Port, error) { } func UpdatePorts(challenge *Challenge) error { - var ports []Port + if challenge == nil || challenge.ID == 0 { + return errors.New("persisted challenge is required") + } DBMux.Lock() defer DBMux.Unlock() - tx := Db.Begin() - - if err := tx.Model(&challenge).Association("Ports").Find(&ports); err != nil { - return fmt.Errorf("error while searching port for challenge : %s", err) - } - tx.Commit() - - if err := Db.Unscoped().Where("challenge_id = ?", challenge.ID).Delete(ports); err != nil { - return err.Error - } - - return nil + return Db.Unscoped().Where("challenge_id = ?", challenge.ID).Delete(&Port{}).Error } func DeleteRelatedPorts(portList []Port) error { + if len(portList) == 0 { + return nil + } + ids := make([]uint, 0, len(portList)) + for _, port := range portList { + if port.ID == 0 { + return errors.New("persisted port is required") + } + ids = append(ids, port.ID) + } DBMux.Lock() defer DBMux.Unlock() - tx := Db.Begin() - - if tx.Error != nil { - return fmt.Errorf("Error while starting transaction : %s", tx.Error) - } + return Db.Unscoped().Where("id IN ?", ids).Delete(&Port{}).Error +} - if err := tx.Where("1 = 1").Unscoped().Delete(portList).Error; err != nil { - tx.Rollback() - return err +func MigratePortUniqueness() error { + if Db == nil { + return errors.New("database is not initialized") } - - return tx.Commit().Error + return Db.Transaction(func(tx *gorm.DB) error { + if err := tx.Exec(` +UPDATE ports +SET server = challenges.server_deployed +FROM challenges +WHERE ports.challenge_id = challenges.id AND ports.server = ''`).Error; err != nil { + return fmt.Errorf("backfill port servers: %w", err) + } + statements := []string{ + `ALTER TABLE ports DROP CONSTRAINT IF EXISTS uni_ports_port_no`, + `ALTER TABLE ports DROP CONSTRAINT IF EXISTS ports_port_no_key`, + `DROP INDEX IF EXISTS idx_ports_port_no`, + `DROP INDEX IF EXISTS uix_ports_port_no`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_ports_server_port ON ports (server, port_no)`, + } + for _, statement := range statements { + if err := tx.Exec(statement).Error; err != nil { + return fmt.Errorf("migrate port uniqueness: %w", err) + } + } + return nil + }) } diff --git a/core/database/relations_test.go b/core/database/relations_test.go new file mode 100644 index 00000000..8d993177 --- /dev/null +++ b/core/database/relations_test.go @@ -0,0 +1,101 @@ +package database + +import ( + "errors" + "testing" + + "gorm.io/gorm" +) + +func TestTagQueriesReturnChallengeRelations(t *testing.T) { + cleanup := setupSubmissionTestDB(t) + defer cleanup() + + challenge := createSubmissionTestChallenge(t, "tagged-challenge", -1, false) + tags := []*Tag{{TagName: "web"}, {TagName: "linux"}} + if err := UpdateTags(tags, &challenge); err != nil { + t.Fatal(err) + } + + related, err := GetRelatedTags(&challenge) + if err != nil { + t.Fatal(err) + } + if len(related) != 2 { + t.Fatalf("expected two related tags, got %d", len(related)) + } + + unique, err := QueryAllUniqueTags() + if err != nil { + t.Fatal(err) + } + if len(unique) != 2 || unique[0] != "linux" || unique[1] != "web" { + t.Fatalf("unexpected unique tags: %v", unique) + } + + challenges, err := QueryRelatedChallenges(&Tag{TagName: "web"}) + if err != nil { + t.Fatal(err) + } + if len(challenges) != 1 || challenges[0].ID != challenge.ID { + t.Fatalf("unexpected related challenges: %#v", challenges) + } + if _, err := QueryRelatedChallenges(&Tag{TagName: "missing"}); !errors.Is(err, gorm.ErrRecordNotFound) { + t.Fatalf("expected missing tag error, got %v", err) + } +} + +func TestPortsAreUniquePerServerAndDeleteByID(t *testing.T) { + cleanup := setupSubmissionTestDB(t) + defer cleanup() + + first := createSubmissionTestChallenge(t, "first-port", -1, false) + first.ServerDeployed = "worker-a" + if err := Db.Model(&first).Update("server_deployed", first.ServerDeployed).Error; err != nil { + t.Fatal(err) + } + second := createSubmissionTestChallenge(t, "second-port", -1, false) + second.ServerDeployed = "worker-b" + if err := Db.Model(&second).Update("server_deployed", second.ServerDeployed).Error; err != nil { + t.Fatal(err) + } + + firstPort, err := PortEntryGetOrCreate(&Port{ChallengeID: first.ID, PortNo: 8080}) + if err != nil { + t.Fatal(err) + } + secondPort, err := PortEntryGetOrCreate(&Port{ChallengeID: second.ID, PortNo: 8080}) + if err != nil { + t.Fatal(err) + } + if firstPort.Server == secondPort.Server { + t.Fatalf("expected distinct port servers, got %q", firstPort.Server) + } + + third := createSubmissionTestChallenge(t, "third-port", -1, false) + third.ServerDeployed = first.ServerDeployed + if err := Db.Model(&third).Update("server_deployed", third.ServerDeployed).Error; err != nil { + t.Fatal(err) + } + existing, err := PortEntryGetOrCreate(&Port{ChallengeID: third.ID, PortNo: 8080}) + if err != nil { + t.Fatal(err) + } + if existing.ChallengeID != first.ID { + t.Fatalf("same-server port should remain owned by challenge %d, got %d", first.ID, existing.ChallengeID) + } + + if err := DeleteRelatedPorts(nil); err != nil { + t.Fatal(err) + } + if err := DeleteRelatedPorts([]Port{firstPort}); err != nil { + t.Fatal(err) + } + var count int64 + if err := Db.Model(&Port{}).Count(&count).Error; err != nil { + t.Fatal(err) + } + if count != 1 { + t.Fatalf("expected only one port after targeted deletion, got %d", count) + } +} diff --git a/core/database/submission_test.go b/core/database/submission_test.go index e1ed908f..f2056479 100644 --- a/core/database/submission_test.go +++ b/core/database/submission_test.go @@ -52,12 +52,15 @@ func setupSubmissionTestDB(t *testing.T) func() { Db = testDB DBMux = &sync.RWMutex{} - if err := Db.AutoMigrate(&Challenge{}, &User{}, &UserChallenges{}, &ChallengeMaintainer{}, &DynamicFlag{}, &DynamicFlagClaim{}, &DynamicScoreDirty{}); err != nil { + if err := Db.AutoMigrate(&Challenge{}, &Port{}, &Tag{}, &User{}, &UserChallenges{}, &ChallengeMaintainer{}, &DynamicFlag{}, &DynamicFlagClaim{}, &DynamicScoreDirty{}); err != nil { t.Fatalf("auto migrate: %v", err) } if err := MigrateSubmissionGuards(); err != nil { t.Fatalf("migrate submission guards: %v", err) } + if err := MigratePortUniqueness(); err != nil { + t.Fatalf("migrate port uniqueness: %v", err) + } return func() { Db = previousDB @@ -115,6 +118,8 @@ func createSubmissionTestChallenge(t *testing.T, name string, maxAttempts int, d MinPoints: 100, MaxAttemptLimit: maxAttempts, Status: core.DEPLOY_STATUS["deployed"], + ContainerId: "container-" + name, + ImageId: "image-" + name, } if err := Db.Create(&challenge).Error; err != nil { t.Fatalf("create challenge %s: %v", name, err) diff --git a/core/database/tag.go b/core/database/tag.go index d377ec05..75422669 100644 --- a/core/database/tag.go +++ b/core/database/tag.go @@ -41,7 +41,9 @@ func QueryRelatedChallenges(tag *Tag) ([]Challenge, error) { DBMux.RLock() defer DBMux.RUnlock() - Db.Where(&Tag{TagName: tag.TagName}).First(&tagName) + if err := Db.Where(&Tag{TagName: tag.TagName}).First(&tagName).Error; err != nil { + return nil, err + } if err := Db.Preload("Tags").Preload("Ports").Model(&tagName).Association("Challenges").Find(&challenges); err != nil { return challenges, err @@ -58,7 +60,9 @@ func QueryRelatedChallengesMetadata(tag *Tag) ([]Challenge, error) { DBMux.RLock() defer DBMux.RUnlock() - Db.Where(&Tag{TagName: tag.TagName}).First(&tagName) + if err := Db.Where(&Tag{TagName: tag.TagName}).First(&tagName).Error; err != nil { + return nil, err + } if err := Db.Model(&tagName). Select("id", "name", "created_at", "points", "difficulty", "instanced", "instance_expiration", "status"). @@ -91,29 +95,19 @@ func QueryTags(whereMap map[string]interface{}) ([]*Tag, error) { } // Update tags -func UpdateTags(tag []*Tag, chall *Challenge) error { - var tags []Tag - +func UpdateTags(tagEntries []*Tag, chall *Challenge) error { DBMux.Lock() defer DBMux.Unlock() - // Delete existing tags - Db.Model(&chall).Association("Tags").Find(&tags) - tx := Db.Begin() - if err := tx.Model(&chall).Association("Tags").Delete(tags); err != nil { - return err - } - - // Create tags - for _, tagEntry := range tag { - if err := tx.FirstOrCreate(tagEntry, *tagEntry).Error; err != nil { - tx.Rollback() - return err - } - if err := tx.Model(&chall).Association("Tags").Append(tagEntry); err != nil { - return err + return Db.Transaction(func(tx *gorm.DB) error { + for _, tagEntry := range tagEntries { + if tagEntry == nil || tagEntry.TagName == "" { + return errors.New("tag name is required") + } + if err := tx.FirstOrCreate(tagEntry, Tag{TagName: tagEntry.TagName}).Error; err != nil { + return err + } } - } - - return tx.Commit().Error + return tx.Model(chall).Association("Tags").Replace(tagEntries) + }) } diff --git a/core/database/user.go b/core/database/user.go index f1f85b51..a3a5baf6 100644 --- a/core/database/user.go +++ b/core/database/user.go @@ -373,7 +373,7 @@ func QueryAllUniqueTags() ([]string, error) { DBMux.RLock() defer DBMux.RUnlock() - tx := Db.Model(&Challenge{}).Distinct().Pluck("tag", &tags) + tx := Db.Model(&Tag{}).Distinct().Order("tag_name").Pluck("tag_name", &tags) if tx.Error != nil { return nil, tx.Error } diff --git a/core/manager/utils.go b/core/manager/utils.go index 7c3462f3..dc261045 100644 --- a/core/manager/utils.go +++ b/core/manager/utils.go @@ -627,6 +627,7 @@ func UpdateOrCreateChallengeDbEntry(challEntry *database.Challenge, config cfg.B portEntry := database.Port{ ChallengeID: challEntry.ID, + Server: challEntry.ServerDeployed, PortNo: port, } From af2fb67457ae15aebb0f83c45146630f2b73a08f Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 15:57:41 +0530 Subject: [PATCH 133/149] fix(database): permit empty runtime identifiers Signed-off-by: vibhatsu --- core/database/challenge_identifiers.go | 34 +++++++++++++++++++++++ core/database/challenges.go | 4 +-- core/database/database.go | 3 +++ core/database/relations_test.go | 37 ++++++++++++++++++++++++++ core/database/submission_test.go | 3 +++ 5 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 core/database/challenge_identifiers.go diff --git a/core/database/challenge_identifiers.go b/core/database/challenge_identifiers.go new file mode 100644 index 00000000..cf89e311 --- /dev/null +++ b/core/database/challenge_identifiers.go @@ -0,0 +1,34 @@ +package database + +import ( + "errors" + "fmt" + + "gorm.io/gorm" +) + +func MigrateChallengeIdentifiers() error { + if Db == nil { + return errors.New("database is not initialized") + } + return Db.Transaction(func(tx *gorm.DB) error { + statements := []string{ + `ALTER TABLE challenges DROP CONSTRAINT IF EXISTS uni_challenges_container_id`, + `ALTER TABLE challenges DROP CONSTRAINT IF EXISTS challenges_container_id_key`, + `ALTER TABLE challenges DROP CONSTRAINT IF EXISTS uni_challenges_image_id`, + `ALTER TABLE challenges DROP CONSTRAINT IF EXISTS challenges_image_id_key`, + `DROP INDEX IF EXISTS idx_challenges_container_id`, + `DROP INDEX IF EXISTS uix_challenges_container_id`, + `DROP INDEX IF EXISTS idx_challenges_image_id`, + `DROP INDEX IF EXISTS uix_challenges_image_id`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_challenges_active_container_id ON challenges (container_id) WHERE container_id <> '' AND deleted_at IS NULL`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_challenges_active_image_id ON challenges (image_id) WHERE image_id <> '' AND deleted_at IS NULL`, + } + for _, statement := range statements { + if err := tx.Exec(statement).Error; err != nil { + return fmt.Errorf("migrate challenge identifiers: %w", err) + } + } + return nil + }) +} diff --git a/core/database/challenges.go b/core/database/challenges.go index 36c43ae8..56cf75de 100644 --- a/core/database/challenges.go +++ b/core/database/challenges.go @@ -50,8 +50,8 @@ type Challenge struct { AdditionalLinks string `gorm:"type:text"` Description string `gorm:"type:text"` Format string `gorm:"not null"` - ContainerId string `gorm:"size:64;unique"` - ImageId string `gorm:"size:64;unique"` + ContainerId string `gorm:"size:64"` + ImageId string `gorm:"size:64"` Status string `gorm:"not null;default:'Undeployed'"` DeploymentType string `gorm:"not null;default:'standard_docker'"` AuthorID uint `gorm:"not null"` diff --git a/core/database/database.go b/core/database/database.go index 17ff0aab..897f365c 100644 --- a/core/database/database.go +++ b/core/database/database.go @@ -116,6 +116,9 @@ func Init() error { if err := MigratePortUniqueness(); err != nil { return fmt.Errorf("migrate port uniqueness: %w", err) } + if err := MigrateChallengeIdentifiers(); err != nil { + return fmt.Errorf("migrate challenge identifiers: %w", err) + } if err := MigrateChallengeMaintainers(); err != nil { return fmt.Errorf("migrate challenge maintainers: %w", err) } diff --git a/core/database/relations_test.go b/core/database/relations_test.go index 8d993177..76234155 100644 --- a/core/database/relations_test.go +++ b/core/database/relations_test.go @@ -99,3 +99,40 @@ func TestPortsAreUniquePerServerAndDeleteByID(t *testing.T) { t.Fatalf("expected only one port after targeted deletion, got %d", count) } } + +func TestChallengeIdentifiersAllowEmptyAndProtectActiveValues(t *testing.T) { + cleanup := setupSubmissionTestDB(t) + defer cleanup() + + first := Challenge{Name: "empty-identifiers-a", Format: "static", AuthorID: 1} + second := Challenge{Name: "empty-identifiers-b", Format: "static", AuthorID: 1} + if err := Db.Create(&first).Error; err != nil { + t.Fatal(err) + } + if err := Db.Create(&second).Error; err != nil { + t.Fatalf("empty identifiers should be reusable: %v", err) + } + + first.ContainerId = "container-id" + first.ImageId = "image-id" + if err := Db.Save(&first).Error; err != nil { + t.Fatal(err) + } + duplicate := Challenge{ + Name: "duplicate-identifiers", + Format: "docker", + AuthorID: 1, + ContainerId: first.ContainerId, + ImageId: first.ImageId, + } + if err := Db.Create(&duplicate).Error; err == nil { + t.Fatal("active challenge identifiers must remain unique") + } + + if err := Db.Delete(&first).Error; err != nil { + t.Fatal(err) + } + if err := Db.Create(&duplicate).Error; err != nil { + t.Fatalf("deleted challenge identifiers should be reusable: %v", err) + } +} diff --git a/core/database/submission_test.go b/core/database/submission_test.go index f2056479..f39c5ec9 100644 --- a/core/database/submission_test.go +++ b/core/database/submission_test.go @@ -61,6 +61,9 @@ func setupSubmissionTestDB(t *testing.T) func() { if err := MigratePortUniqueness(); err != nil { t.Fatalf("migrate port uniqueness: %v", err) } + if err := MigrateChallengeIdentifiers(); err != nil { + t.Fatalf("migrate challenge identifiers: %v", err) + } return func() { Db = previousDB From 1a678df3f197cc10ead05ca508481e9f7e312354 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 15:58:29 +0530 Subject: [PATCH 134/149] fix(shutdown): preserve deployed challenges Signed-off-by: vibhatsu --- cmd/beast/run.go | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/cmd/beast/run.go b/cmd/beast/run.go index 1d5a7f18..9dbb2cb7 100644 --- a/cmd/beast/run.go +++ b/cmd/beast/run.go @@ -114,32 +114,6 @@ func stopRemoteManagers() { log.Infoln("Remote Manager queue stopped") } -func cleanupRunningContainers() { - log.Infoln("Cleaning up running challenges...") - - challenges, err := database.QueryAllChallenges() - if err != nil { - log.Errorln(fmt.Sprintf("Error while querying challenges for cleanup: %s", err.Error())) - return - } - - for _, challenge := range challenges { - if challenge.Status == core.DEPLOY_STATUS["deployed"] { - if challenge.Instanced { - _ = manager.KillChallengeInstances(challenge.Name) - } - - err = manager.StartUndeployChallenge(challenge.Name, false) - if err != nil { - log.Errorln(fmt.Sprintf("Failed to undeploy challenge [Id: %v] %s", challenge.ID, challenge.Name)) - log.Errorln(err.Error()) - } else { - log.Infoln(fmt.Sprintf("Successfully undeployed challenge [Id: %v] %s", challenge.ID, challenge.Name)) - } - } - } -} - func cleanupCacheConnections() { log.Infoln("Cleaning up cache connections...") log.Infoln("Terminating cache connection...") @@ -215,8 +189,6 @@ func cleanup() { stopWorkerQueue() stopSseNotificationHub() - cleanupRunningContainers() - stopRemoteManagers() saveLeaderboardCache() From b712ef386b8a3b676060ad3c35d8eb82603e4da7 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 15:59:09 +0530 Subject: [PATCH 135/149] fix(cache): replace snapshots atomically Signed-off-by: vibhatsu --- cmd/beast/run.go | 26 +++++++++++++++++++++++++- cmd/beast/run_test.go | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/cmd/beast/run.go b/cmd/beast/run.go index 9dbb2cb7..f3c4d448 100644 --- a/cmd/beast/run.go +++ b/cmd/beast/run.go @@ -149,7 +149,31 @@ func writeJson(data any, location string) error { return err } - return os.WriteFile(location, bytes, 0644) + temporary, err := os.CreateTemp(filepath.Dir(location), "."+filepath.Base(location)+".tmp-*") + if err != nil { + return fmt.Errorf("create temporary JSON file: %w", err) + } + temporaryPath := temporary.Name() + defer os.Remove(temporaryPath) + if err := temporary.Chmod(0600); err != nil { + _ = temporary.Close() + return fmt.Errorf("secure temporary JSON file: %w", err) + } + if _, err := temporary.Write(bytes); err != nil { + _ = temporary.Close() + return fmt.Errorf("write temporary JSON file: %w", err) + } + if err := temporary.Sync(); err != nil { + _ = temporary.Close() + return fmt.Errorf("sync temporary JSON file: %w", err) + } + if err := temporary.Close(); err != nil { + return fmt.Errorf("close temporary JSON file: %w", err) + } + if err := os.Rename(temporaryPath, location); err != nil { + return fmt.Errorf("replace JSON file: %w", err) + } + return nil } func saveLeaderboardCache() { diff --git a/cmd/beast/run_test.go b/cmd/beast/run_test.go index 0d600c9e..665782cf 100644 --- a/cmd/beast/run_test.go +++ b/cmd/beast/run_test.go @@ -59,3 +59,40 @@ func TestControllerLockCanBeReacquiredAfterRelease(t *testing.T) { } releaseControllerLock(second) } + +func TestWriteJSONReplacesAtomicallyWithPrivatePermissions(t *testing.T) { + directory := t.TempDir() + outside := filepath.Join(t.TempDir(), "outside") + if err := os.WriteFile(outside, []byte("keep"), 0600); err != nil { + t.Fatal(err) + } + location := filepath.Join(directory, "cache.json") + if err := os.Symlink(outside, location); err != nil { + t.Fatal(err) + } + + if err := writeJson(map[string]string{"state": "fresh"}, location); err != nil { + t.Fatal(err) + } + contents, err := os.ReadFile(location) + if err != nil { + t.Fatal(err) + } + if string(contents) != `{"state":"fresh"}` { + t.Fatalf("unexpected JSON cache %q", contents) + } + outsideContents, err := os.ReadFile(outside) + if err != nil { + t.Fatal(err) + } + if string(outsideContents) != "keep" { + t.Fatalf("symlink target was overwritten: %q", outsideContents) + } + info, err := os.Stat(location) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0600 { + t.Fatalf("cache permissions = %04o, want 0600", info.Mode().Perm()) + } +} From f9378e96ee1a24acaa64362f02fcc45970a58a51 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 15:59:28 +0530 Subject: [PATCH 136/149] fix(api): preserve controlled panic recovery Signed-off-by: vibhatsu --- api/main.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/api/main.go b/api/main.go index ca844533..e74ddf0d 100644 --- a/api/main.go +++ b/api/main.go @@ -154,7 +154,6 @@ func RunBeastApiServer(ctx context.Context, port, defaultauthorpassword string, // Setup gin middlewares router.Use(gin.Logger()) - router.Use(gin.Recovery()) router.GET("/api/docs/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) router.GET("/", func(c *gin.Context) { @@ -174,7 +173,9 @@ func RunBeastApiServer(ctx context.Context, port, defaultauthorpassword string, if periodicSync { log.Infof("Scheduling periodic remote sync and auto update for beast with period: %v", config.Cfg.RemoteSyncPeriod) - BeastScheduler.ScheduleEvery(config.Cfg.RemoteSyncPeriod, manager.AutoUpdate) + if err := BeastScheduler.ScheduleEvery(config.Cfg.RemoteSyncPeriod, manager.AutoUpdate); err != nil { + return fmt.Errorf("schedule periodic remote sync: %w", err) + } } if autoDeploy { From c492449d9e1823d6467818206e76fb6626b72861 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 15:59:48 +0530 Subject: [PATCH 137/149] perf(remote): avoid duplicate startup connections Signed-off-by: vibhatsu --- pkg/remoteManager/init.go | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/pkg/remoteManager/init.go b/pkg/remoteManager/init.go index f9724caa..038cd549 100644 --- a/pkg/remoteManager/init.go +++ b/pkg/remoteManager/init.go @@ -18,13 +18,7 @@ func Init() error { if config.Cfg.UseLocalDockerDaemon(serverDeployed) { continue } - client, err := CreateSSHClient(server) - if err != nil { - failures = append(failures, fmt.Errorf("connect to remote %s: %w", serverDeployed, err)) - continue - } - _ = client.Close() - _, err = RunArgsOnServer(server, "mkdir", "-p", "--", filepath.Join(core.BEAST_REMOTE_GLOBAL_DIR, core.BEAST_STAGING_DIR)) + _, err := RunArgsOnServer(server, "mkdir", "-p", "--", filepath.Join(core.BEAST_REMOTE_GLOBAL_DIR, core.BEAST_STAGING_DIR)) if err != nil { failures = append(failures, fmt.Errorf("prepare remote %s: %w", serverDeployed, err)) continue From 8cafb5a31b68c950edf0115c080a3c3eb793cd23 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 16:01:38 +0530 Subject: [PATCH 138/149] fix(scaffold): generate valid challenge configs Signed-off-by: vibhatsu --- cmd/beast/generate_template_test.go | 8 ++++++++ core/config/challenge.go | 16 +++++++-------- templates/templates.go | 31 ++++++++++++++++------------- 3 files changed, 33 insertions(+), 22 deletions(-) diff --git a/cmd/beast/generate_template_test.go b/cmd/beast/generate_template_test.go index 4912b115..a8f0efd0 100644 --- a/cmd/beast/generate_template_test.go +++ b/cmd/beast/generate_template_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/sdslabs/beastv4/core" + challengeConfig "github.com/sdslabs/beastv4/core/config" ) func TestGenerateChallengeTemplateRefusesOverwrite(t *testing.T) { @@ -38,4 +39,11 @@ func TestGenerateChallengeTemplateUsesPrivateConfig(t *testing.T) { if info.Mode().Perm() != 0600 { t.Fatalf("template mode = %04o, want 0600", info.Mode().Perm()) } + configuration, err := challengeConfig.LoadChallengeConfig(filepath.Join(directory, core.CHALLENGE_CONFIG_FILE_NAME)) + if err != nil { + t.Fatalf("generated configuration is not valid TOML: %v", err) + } + if configuration.Challenge.Metadata.Type != core.STATIC_CHALLENGE_TYPE_NAME || configuration.Challenge.Env.StaticContentDir != core.PUBLIC { + t.Fatalf("unexpected generated challenge defaults: %#v", configuration.Challenge) + } } diff --git a/core/config/challenge.go b/core/config/challenge.go index 1bea8c99..2a5259cd 100644 --- a/core/config/challenge.go +++ b/core/config/challenge.go @@ -41,24 +41,24 @@ func (config *BeastChallengeConfig) PopulateDefaultValues() { } func (Author *Author) PopulateAuthor() { - Author.Name = "AuthorName" - Author.Email = "AuthorMail" + Author.Name = "Author Name" + Author.Email = "author@example.com" } func (Metadata *ChallengeMetadata) PopulateChallengeMetadata() { - Metadata.Name = "ChallengeName" - Metadata.Type = "ChallengeType" + Metadata.Name = "challenge-name" + Metadata.Type = core.STATIC_CHALLENGE_TYPE_NAME Metadata.DynamicFlag = false - Metadata.Flag = "ChallengeFlag" + Metadata.Flag = "flag{replace-me}" + Metadata.Difficulty = "medium" + Metadata.Points = 100 } func (Env *ChallengeEnv) PopulateChallengeEnv() { Env.AptDeps = []string{} Env.Ports = []uint32{} Env.SetupScripts = []string{} - Env.StaticContentDir = "StaticContentDir" - Env.BaseImage = "ChallengeBase" - Env.RunCmd = "RunCmd" + Env.StaticContentDir = core.PUBLIC } func (config *BeastChallengeConfig) ValidateRequiredFields(challdir string) error { diff --git a/templates/templates.go b/templates/templates.go index 35faeaf4..46f9092c 100644 --- a/templates/templates.go +++ b/templates/templates.go @@ -1,24 +1,27 @@ package templates -var CHALLENGE_CONFIG_FILE_TEMPLATE string = `# This a sample challenge configuration file. +var CHALLENGE_CONFIG_FILE_TEMPLATE string = `# Beast challenge configuration. Replace the example metadata before deployment. [author] -name = {{.Author.Name}} # Required: Name of the challenge creator -email = {{.Author.Email}} # Required: Email for contact +name = {{printf "%q" .Author.Name}} +email = {{printf "%q" .Author.Email}} [challenge.metadata] -name = {{.Challenge.Metadata.Name}} # Required: Name of the challenge, should be same as directory. -type = {{.Challenge.Metadata.Type}} # Required: Type of challenge -> [web::: static service] -dynamicFlag = {{.Challenge.Metadata.DynamicFlag}} # Required: Dynamic flag or not -> [true/false] -flag = {{.Challenge.Metadata.Flag}} # Challenge Flag if dynamicFlag is false -difficulty = {{.Challenge.Metadata.Difficulty}} # Specify the difficulty of the challenge +name = {{printf "%q" .Challenge.Metadata.Name}} +type = {{printf "%q" .Challenge.Metadata.Type}} +dynamicFlag = {{.Challenge.Metadata.DynamicFlag}} +flag = {{printf "%q" .Challenge.Metadata.Flag}} +difficulty = {{printf "%q" .Challenge.Metadata.Difficulty}} +points = {{.Challenge.Metadata.Points}} +maxAttemptLimit = 0 +tags = [] +assets = [] +additionalLinks = [] [challenge.env] -apt_deps = {{.Challenge.Env.AptDeps}} # Custom apt-dependencies for challenge -ports = {{.Challenge.Env.Ports}} # Required: Port to expose for the challenge -setup_script = {{.Challenge.Env.SetupScripts}} # Setup script to run additional steps for challenge deployment -static_dir = {{.Challenge.Env.StaticContentDir}} # Static directory to be served for the challenge -base = {{.Challenge.Env.BaseImage}} # Base image-type for the challenge[bare("web", "service"), php(web), node(web)] -run_cmd = {{.Challenge.Env.RunCmd}} # Required(not for web): Entrypoint command for the challenge container(for bare base specify compelete command) +static_dir = {{printf "%q" .Challenge.Env.StaticContentDir}} + +[resource] +# Omitted limits inherit the administrator-defined global defaults. ` var BEAST_DOCKERFILE_TEMPLATE string = `# Beast Dockerfile From 22d2c69ec63aaa2666cce1e3933b14615afcd0a9 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 16:02:36 +0530 Subject: [PATCH 139/149] build: make documentation commands reproducible Signed-off-by: vibhatsu --- Makefile | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/Makefile b/Makefile index e26a47d9..a314d5e8 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,6 @@ GO := go AIR := $(shell $(GO) env GOPATH)/bin/air +BEAST_BIN := $(if $(BEAST_OUTPUT),$(BEAST_OUTPUT),$(shell $(GO) env GOPATH)/bin/beast) pkgs = $(shell $(GO) list ./... | grep -v vendor) @@ -16,7 +17,7 @@ help: # Build beast build: - @./scripts/build/build.sh + @BEAST_OUTPUT="$(BEAST_BIN)" ./scripts/build/build.sh # Run development environment dev: @@ -24,7 +25,7 @@ dev: @$(AIR) cmdref: build - @${GOPATH}/bin/beast cmdref + @"$(BEAST_BIN)" cmdref --reference-directory docs/cmdref # Check go formatting check_format: @@ -60,11 +61,11 @@ requirements: docs: @rm -rf site/ @echo ">>> Building Documentation" - @mkdocs build - @python scripts/tools/swagger-docs.py + @mkdocs build --strict + @python3 scripts/tools/swagger-docs.py installenv: @echo 'Setting up environment for beast.' @./scripts/installenv.sh -.PHONY: build format test test-race integration-test check_format docs installenv govet +.PHONY: build cmdref format test test-race integration-test check_format docs installenv govet requirements From ca98f13a4d6a7b52d86b46d2ee9bad7a6d2d48cc Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 16:03:42 +0530 Subject: [PATCH 140/149] fix(setup): collect valid worker settings Signed-off-by: vibhatsu --- cmd/beast/config.go | 11 +++++++++-- cmd/beast/init.go | 2 +- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/cmd/beast/config.go b/cmd/beast/config.go index 05f97324..2aac7352 100644 --- a/cmd/beast/config.go +++ b/cmd/beast/config.go @@ -40,8 +40,15 @@ func promptServerDetails(configuration *config.BeastConfig) { if server.Host == "" { server.Host = core.LOCALHOST } - server.Username = utils.PromptString("Enter Username") - server.SSHKeyPath = utils.PromptString("Enter SSH Key Path") + if server.Host != core.LOCALHOST && server.Host != core.LOCALHOST_IP { + server.Username = utils.PromptString("Enter SSH username") + server.SSHKeyPath = utils.PromptString("Enter SSH private key path (must be mode 0600)") + server.KnownHostsFile = utils.PromptString("Enter known_hosts path (leave empty for $HOME/.ssh/known_hosts)") + } + server.PortRange = utils.PromptString("Enter allocatable host port range (leave empty for 10000:20000)") + if server.PortRange == "" { + server.PortRange = "10000:20000" + } server.Active = utils.PromptBinary("Enable this server?") configuration.AvailableServers[server.Host] = server diff --git a/cmd/beast/init.go b/cmd/beast/init.go index e3a28493..ef790d1d 100644 --- a/cmd/beast/init.go +++ b/cmd/beast/init.go @@ -367,7 +367,7 @@ func runBeastBootsteps() error { var initCmd = &cobra.Command{ Use: "init", Short: "Run Beast initial setup bootsetps.", - Long: "Initializes beast by setting up beast directory, checking for permission. It also configures the logger and local SQLite database to be used by beast", + Long: "Initializes Beast directories, configuration, TLS, Redis ACLs, PostgreSQL schema, and an optional administrator account.", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { From 70c9639f7bdd813e59bac22f8a8b7c244686575a Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 16:04:48 +0530 Subject: [PATCH 141/149] feat(cli): verify local challenge configs Signed-off-by: vibhatsu --- cmd/beast/commands.go | 1 + cmd/beast/verify.go | 16 ++++++++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/cmd/beast/commands.go b/cmd/beast/commands.go index 0f7a5dcf..96015250 100644 --- a/cmd/beast/commands.go +++ b/cmd/beast/commands.go @@ -93,6 +93,7 @@ func init() { challengeCmd.PersistentFlags().StringVarP(&LocalDirectory, "local-directory", "l", "", "Deploys challenge from local directory") challengeCmd.PersistentFlags().BoolVarP(&DeleteEntry, "delete-entry", "d", false, "Deletes db entry related to this challenge") challengeCmd.PersistentFlags().BoolVarP(&NoCache, "no-cache", "c", false, "Build image of challenge without using cache") + verifyCmd.Flags().StringVarP(&LocalDirectory, "local-directory", "l", "", "Validate a challenge from a local directory") cmdRef.PersistentFlags().StringVarP(&RefDirectory, "reference-directory", "r", "", "Generate beast command reference files in reference directory") diff --git a/cmd/beast/verify.go b/cmd/beast/verify.go index 664ca60c..1fff895f 100644 --- a/cmd/beast/verify.go +++ b/cmd/beast/verify.go @@ -11,14 +11,26 @@ import ( // Verifies the challenge config var verifyCmd = &cobra.Command{ - Use: "verify challenge-name", + Use: "verify [challenge-name]", Short: "Verifies challenge config", - Args: cobra.ExactArgs(1), + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { if err := config.InitConfig(); err != nil { return err } + if LocalDirectory != "" { + if len(args) != 0 { + return fmt.Errorf("challenge name and local-directory are mutually exclusive") + } + if err := manager.ValidateChallengeConfig(LocalDirectory); err != nil { + return fmt.Errorf("validate local challenge: %w", err) + } + return nil + } + if len(args) == 0 { + return fmt.Errorf("challenge name or local-directory is required") + } challengeName := args[0] challengeDir := coreUtils.GetChallengeDir(challengeName) From 201fe2c86deea8480a521a66b1925320eeb462a9 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 16:10:13 +0530 Subject: [PATCH 142/149] fix(config): allow setup-generated services Signed-off-by: vibhatsu --- _examples/instanced-service/beast.toml | 2 +- _examples/static-chall/static/index.html | 10 ++++++ core/config/challenge.go | 21 ++++++++++- core/config/examples_test.go | 44 ++++++++++++++++++++++++ 4 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 _examples/static-chall/static/index.html create mode 100644 core/config/examples_test.go diff --git a/_examples/instanced-service/beast.toml b/_examples/instanced-service/beast.toml index cd08f5d9..a00a2404 100644 --- a/_examples/instanced-service/beast.toml +++ b/_examples/instanced-service/beast.toml @@ -28,7 +28,7 @@ default_port = 9999 apt_deps = ["gcc", "xinetd"] setup_scripts = ["setup.sh"] service_path = "pwn" -base_image = "ubuntu:18.04" +base_image = "ubuntu:24.04" [resource] cpu_shares = 512 diff --git a/_examples/static-chall/static/index.html b/_examples/static-chall/static/index.html new file mode 100644 index 00000000..bf53541e --- /dev/null +++ b/_examples/static-chall/static/index.html @@ -0,0 +1,10 @@ + + + + + Beast static challenge example + + +

This public test page is served by Beast's shared static-content service.

+ + diff --git a/core/config/challenge.go b/core/config/challenge.go index 2a5259cd..57aac98e 100644 --- a/core/config/challenge.go +++ b/core/config/challenge.go @@ -5,6 +5,8 @@ import ( "fmt" "net/mail" "net/url" + "os" + "path/filepath" "regexp" "strings" @@ -20,6 +22,7 @@ const SERVICE_CHALL_RUN_CMD string = "xinetd -dontfork" var challengeNamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,63}$`) var environmentKeyPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) +var generatedPathPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]{0,255}$`) // This is the beast challenge config file structure // any other field specified in the file other than this structure @@ -393,6 +396,22 @@ func validateChallengeDir(challengeDir, relativePath, field string) error { return nil } +func validateGeneratedChallengeFile(challengeDir, relativePath, field string, setupScripts []string) error { + cleaned := filepath.ToSlash(filepath.Clean(relativePath)) + if !generatedPathPattern.MatchString(cleaned) || cleaned == "." || strings.Contains(cleaned, "../") { + return fmt.Errorf("invalid %s path %q", field, relativePath) + } + if err := validateChallengeFile(challengeDir, cleaned, field); err == nil { + return nil + } else if _, statErr := os.Lstat(filepath.Join(challengeDir, filepath.FromSlash(cleaned))); !os.IsNotExist(statErr) { + return err + } + if len(setupScripts) == 0 { + return fmt.Errorf("%s %q does not exist and no setup script generates it", field, relativePath) + } + return nil +} + // ValidateRequiredFields validates required fields for the Challenge environment configuration. // This requires challenge type to be passed so that we can verfiy based on type // of the challenge. @@ -477,7 +496,7 @@ func (config *ChallengeEnv) ValidateRequiredFields(challType string, challdir st // Challenge type is service. // ServicePath must be relative. if config.ServicePath != "" { - if err := validateChallengeFile(challdir, config.ServicePath, "service_path"); err != nil { + if err := validateGeneratedChallengeFile(challdir, config.ServicePath, "service_path", config.SetupScripts); err != nil { return err } } diff --git a/core/config/examples_test.go b/core/config/examples_test.go new file mode 100644 index 00000000..3014e217 --- /dev/null +++ b/core/config/examples_test.go @@ -0,0 +1,44 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestMaintainedExamplesValidate(t *testing.T) { + previous := Cfg + Cfg = &BeastConfig{ + AllowedBaseImages: []string{"ubuntu:24.04", "debian:bookworm"}, + CPUShares: 1024, + CPUsLimit: 1, + Memory: 1 << 30, + PidsLimit: 256, + } + defer func() { Cfg = previous }() + + examplesRoot := filepath.Join("..", "..", "_examples") + entries, err := os.ReadDir(examplesRoot) + if err != nil { + t.Fatal(err) + } + for _, entry := range entries { + if !entry.IsDir() { + continue + } + challengeDir := filepath.Join(examplesRoot, entry.Name()) + configPath := filepath.Join(challengeDir, "beast.toml") + if _, err := os.Stat(configPath); os.IsNotExist(err) { + continue + } + t.Run(entry.Name(), func(t *testing.T) { + configuration, err := LoadChallengeConfig(configPath) + if err != nil { + t.Fatal(err) + } + if err := configuration.ValidateRequiredFields(challengeDir); err != nil { + t.Fatal(err) + } + }) + } +} From 7c95ba49374b61baa6e23a574fc0c3be26452d96 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 16:11:09 +0530 Subject: [PATCH 143/149] build(docs): regenerate command references cleanly Signed-off-by: vibhatsu --- Makefile | 1 + cmd/beast/cmdref.go | 1 + 2 files changed, 2 insertions(+) diff --git a/Makefile b/Makefile index a314d5e8..e1f75dcd 100644 --- a/Makefile +++ b/Makefile @@ -25,6 +25,7 @@ dev: @$(AIR) cmdref: build + @rm -rf docs/cmdref @"$(BEAST_BIN)" cmdref --reference-directory docs/cmdref # Check go formatting diff --git a/cmd/beast/cmdref.go b/cmd/beast/cmdref.go index 3a355208..d29c585b 100644 --- a/cmd/beast/cmdref.go +++ b/cmd/beast/cmdref.go @@ -21,6 +21,7 @@ var cmdRef = &cobra.Command{ if err := os.MkdirAll(directory, 0750); err != nil { return fmt.Errorf("create command reference directory: %w", err) } + rootCmd.DisableAutoGenTag = true return doc.GenMarkdownTree(rootCmd, directory) }, } From 4b5f0ccf1c91aff3374801a867396e0e6a88f12a Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 16:20:39 +0530 Subject: [PATCH 144/149] fix(authz): enforce challenge management ownership Signed-off-by: vibhatsu --- api/challenge_authorization.go | 90 +++++++++++++++++++++++++++++ api/challenge_authorization_test.go | 34 +++++++++++ api/info.go | 22 ++++--- api/manage.go | 58 ++++++++++++++++--- api/router.go | 10 ++-- 5 files changed, 193 insertions(+), 21 deletions(-) create mode 100644 api/challenge_authorization.go create mode 100644 api/challenge_authorization_test.go diff --git a/api/challenge_authorization.go b/api/challenge_authorization.go new file mode 100644 index 00000000..ab1d13a8 --- /dev/null +++ b/api/challenge_authorization.go @@ -0,0 +1,90 @@ +package api + +import ( + "errors" + "net/http" + "path/filepath" + "strings" + + "github.com/gin-gonic/gin" + "github.com/sdslabs/beastv4/core" + challengeConfig "github.com/sdslabs/beastv4/core/config" + "github.com/sdslabs/beastv4/core/database" + coreUtils "github.com/sdslabs/beastv4/core/utils" + "github.com/sdslabs/beastv4/pkg/auth" + "gorm.io/gorm" +) + +func authenticatedManager(c *gin.Context) (database.User, bool) { + claimsValue, exists := c.Get("authClaims") + claims, valid := claimsValue.(*auth.CustomClaims) + if !exists || !valid { + c.AbortWithStatusJSON(http.StatusUnauthorized, HTTPErrorResp{Error: "verified authentication claims are required"}) + return database.User{}, false + } + user, err := database.QueryFirstUserEntry("username", claims.User) + if err != nil || user.ID == 0 || user.Status != 0 { + c.AbortWithStatusJSON(http.StatusForbidden, HTTPErrorResp{Error: "challenge management access denied"}) + return database.User{}, false + } + return user, true +} + +func userOwnsChallengeConfig(user database.User, configuration challengeConfig.BeastChallengeConfig) bool { + if user.Role == core.USER_ROLES["admin"] { + return true + } + if strings.EqualFold(user.Email, configuration.Author.Email) { + return true + } + for _, maintainer := range configuration.Maintainers { + if strings.EqualFold(user.Email, maintainer.Email) { + return true + } + } + return false +} + +func authorizeChallengeManagement(c *gin.Context, challengeName string, allowRemoteConfig bool) bool { + user, ok := authenticatedManager(c) + if !ok { + return false + } + challenge, err := database.QueryFirstChallengeEntry("name", challengeName) + if err == nil { + maintainer, relationErr := database.IsChallengeMaintainer(user.ID, challenge.ID) + if relationErr != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, HTTPErrorResp{Error: "failed to authorize challenge access"}) + return false + } + if !userCanExecChallenge(user, challenge, maintainer) { + c.AbortWithStatusJSON(http.StatusForbidden, HTTPErrorResp{Error: "challenge management access denied"}) + return false + } + return true + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + c.AbortWithStatusJSON(http.StatusInternalServerError, HTTPErrorResp{Error: "failed to query challenge"}) + return false + } + if !allowRemoteConfig { + c.AbortWithStatusJSON(http.StatusNotFound, HTTPErrorResp{Error: "challenge not found"}) + return false + } + + challengeDir := coreUtils.GetChallengeDir(challengeName) + if challengeDir == "" { + c.AbortWithStatusJSON(http.StatusNotFound, HTTPErrorResp{Error: "challenge not found"}) + return false + } + configuration, loadErr := challengeConfig.LoadChallengeConfig(filepath.Join(challengeDir, core.CHALLENGE_CONFIG_FILE_NAME)) + if loadErr != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, HTTPErrorResp{Error: "challenge configuration is invalid"}) + return false + } + if !userOwnsChallengeConfig(user, configuration) { + c.AbortWithStatusJSON(http.StatusForbidden, HTTPErrorResp{Error: "challenge management access denied"}) + return false + } + return true +} diff --git a/api/challenge_authorization_test.go b/api/challenge_authorization_test.go new file mode 100644 index 00000000..1141ed99 --- /dev/null +++ b/api/challenge_authorization_test.go @@ -0,0 +1,34 @@ +package api + +import ( + "testing" + + "github.com/sdslabs/beastv4/core" + challengeConfig "github.com/sdslabs/beastv4/core/config" + "github.com/sdslabs/beastv4/core/database" + "github.com/sdslabs/beastv4/pkg/auth" +) + +func TestUserOwnsChallengeConfig(t *testing.T) { + configuration := challengeConfig.BeastChallengeConfig{ + Author: challengeConfig.Author{Email: "author@example.com"}, + Maintainers: []challengeConfig.Author{{Email: "maintainer@example.com"}}, + } + tests := []struct { + name string + user database.User + want bool + }{ + {name: "author", user: database.User{Email: "AUTHOR@example.com"}, want: true}, + {name: "maintainer", user: database.User{Email: "maintainer@example.com"}, want: true}, + {name: "unrelated", user: database.User{Email: "other@example.com"}, want: false}, + {name: "admin", user: database.User{Email: "other@example.com", AuthModel: auth.AuthModel{Role: core.USER_ROLES["admin"]}}, want: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := userOwnsChallengeConfig(test.user, configuration); got != test.want { + t.Fatalf("userOwnsChallengeConfig() = %t, want %t", got, test.want) + } + }) + } +} diff --git a/api/info.go b/api/info.go index 73e1db72..804de024 100644 --- a/api/info.go +++ b/api/info.go @@ -166,12 +166,12 @@ func hintHandler(c *gin.Context) { // @Accept json // @Produce json // @Param Authorization header string true "Bearer" -// @Param name query string true "Name of challenge" -// @Success 200 {object} api.ChallengeInfoResp +// @Param name path string true "Name of challenge" +// @Success 200 {object} api.Challenge // @Failure 400 {object} api.HTTPErrorResp // @Failure 404 {object} api.HTTPErrorResp // @Failure 500 {object} api.HTTPErrorResp -// @Router /api/info/challenge/info [get] +// @Router /api/info/challenge/{name} [get] func challengeInfoHandler(c *gin.Context) { name := c.Param("name") if name == "" { @@ -308,7 +308,7 @@ func canViewChallengeSecrets(user *database.User, challenge *database.Challenge) // @Param filter query string false "Filter parameter by which challenges are filtered" // @Param value query string false "Value of filtered parameter" // @Param Authorization header string true "Bearer" -// @Success 200 {object} api.ChallengeInfoResp +// @Success 200 {array} api.ChallengeMetadata // @Failure 400 {object} api.HTTPErrorResp // @Failure 500 {object} api.HTTPErrorResp // @Router /api/info/challenges [get] @@ -485,6 +485,9 @@ func challengeLogsHandler(c *gin.Context) { }) return } + if !authorizeChallengeManagement(c, chall, false) { + return + } logs, err := utils.GetLogs(chall, false) if err != nil { @@ -621,8 +624,11 @@ func userInfoHandler(c *gin.Context) { // @Accept json // @Produce json // @Param Authorization header string true "Bearer" -// @Param sort, order, filter -// @Success 200 {object} api.UserResp +// @Param sort query string false "Sort by username or score" +// @Param order query string false "Score order: asc or desc" +// @Param filter query string false "Filter by banned, active, or hidden" +// @Param format query string false "Response format: json or csv" +// @Success 200 {array} api.UsersResp // @Failure 404 {object} api.HTTPErrorResp // @Failure 500 {object} api.HTTPErrorResp // @Router /api/info/users [get] @@ -838,7 +844,7 @@ func submissionsHandler(c *gin.Context) { // @Success 200 {object} api.UsersStatisticsResp // @Failure 404 {object} api.HTTPErrorResp // @Failure 500 {object} api.HTTPErrorResp -// @Router /api/admin/statistics [get] +// @Router /api/info/competition-info [get] func getUsersStatisticsHandler(c *gin.Context) { users, err := database.QueryAllUsers() if err != nil { @@ -884,7 +890,7 @@ func getUsersStatisticsHandler(c *gin.Context) { // @Param Authorization header string true "Bearer" // @Success 200 {object} api.CompetitionInfoResp // @Failure 400 {object} api.HTTPErrorResp -// @Router /api/admin/statistics [get] +// @Router /api/info/tags [get] func competitionInfoHandler(c *gin.Context) { competitionInfo, err := config.GetCompetitionInfo() if err != nil { diff --git a/api/manage.go b/api/manage.go index 61b6174f..7fbc7211 100644 --- a/api/manage.go +++ b/api/manage.go @@ -51,7 +51,7 @@ func manageMultipleChallengeHandlerTagBased(c *gin.Context) { // Since upto this point the request is already authorized, we use a default // username if any error occurs while getting the username. username, err := coreUtils.GetUser(c.GetHeader("Authorization")) - if err == nil { + if err != nil { log.Warnf("Error while getting user from authorization header, using default user(since already authorized)") username = core.DEFAULT_USER_NAME } @@ -105,6 +105,9 @@ func manageChallengeHandler(c *gin.Context) { identifier := c.PostForm("name") action := c.PostForm("action") authorization := c.GetHeader("Authorization") + if !authorizeChallengeManagement(c, identifier, action == core.MANAGE_ACTION_DEPLOY) { + return + } log.Infof("Trying %s for challenge with identifier : %s", action, identifier) if msgs := manager.LogTransaction(identifier, action, authorization); msgs != nil { @@ -222,9 +225,26 @@ func deployLocalChallengeHandler(c *gin.Context) { }) return } + if err := manager.ValidateChallengeConfig(challDir); err != nil { + c.JSON(http.StatusBadRequest, HTTPErrorResp{Error: err.Error()}) + return + } + configuration, err := cfg.LoadChallengeConfig(filepath.Join(challDir, core.CHALLENGE_CONFIG_FILE_NAME)) + if err != nil { + c.JSON(http.StatusBadRequest, HTTPErrorResp{Error: "challenge configuration is invalid"}) + return + } + user, ok := authenticatedManager(c) + if !ok { + return + } + if !userOwnsChallengeConfig(user, configuration) { + c.JSON(http.StatusForbidden, HTTPErrorResp{Error: "challenge management access denied"}) + return + } log.Info("In local deploy challenge Handler") - err := manager.DeployChallengePipeline(challDir) + err = manager.DeployChallengePipeline(challDir) if msgs := manager.LogTransaction(strings.Split(challDir, "/")[len(strings.Split(challDir, "/"))-1], action, authorization); msgs != nil { log.Warn("Error while saving transaction") } @@ -302,9 +322,12 @@ func beastStaticContentHandler(c *gin.Context) { // @Param challenge query string true "Name of the challenge to commit" // @Success 200 {object} api.HTTPPlainResp // @Failure 500 {object} api.HTTPPlainResp -// @Router /api/manage/commit/ [post] +// @Router /api/manage/challenge/verify [post] func commitChallenge(c *gin.Context) { challenge := c.PostForm("challenge") + if !authorizeChallengeManagement(c, challenge, false) { + return + } err := manager.CommitChallengeContainer(challenge) @@ -331,6 +354,9 @@ func commitChallenge(c *gin.Context) { // @Router /api/manage/commit/ [post] func verifyHandler(c *gin.Context) { challengeName := c.PostForm("challenge") + if !authorizeChallengeManagement(c, challengeName, true) { + return + } challengeRemoteDir := coreUtils.GetChallengeDir(challengeName) if challengeRemoteDir == "" { log.Errorf("Challenge does not exist") @@ -373,7 +399,7 @@ func manageScheduledAction(c *gin.Context) { authorization := c.GetHeader("Authorization") username, err := coreUtils.GetUser(authorization) - if err == nil { + if err != nil { log.Warn("Error while getting user from authorization header, using default user(since already authorized)") username = core.DEFAULT_USER_NAME } @@ -426,12 +452,18 @@ func manageScheduledAction(c *gin.Context) { if tag != "" { manager.LogTransaction(fmt.Sprintf("TAG:%s", tag), "SCHEDULE::"+action, authorization) - BeastScheduler.ScheduleAfter(duration, manager.HandleTagRelatedChallenges, action, tag, username) + if err := BeastScheduler.ScheduleAfter(duration, manager.HandleTagRelatedChallenges, action, tag, username); err != nil { + c.JSON(http.StatusInternalServerError, HTTPErrorResp{Error: "failed to schedule challenge action"}) + return + } log.Infof("Scheduled %s for challenges with tag %s", action, tag) } else { manager.LogTransaction(challenge, "SCHEDULE::"+action, authorization) - BeastScheduler.ScheduleAfter(duration, actionHandler, challenge) + if err := BeastScheduler.ScheduleAfter(duration, actionHandler, challenge); err != nil { + c.JSON(http.StatusInternalServerError, HTTPErrorResp{Error: "failed to schedule challenge action"}) + return + } log.Infof("Scheduled %s for challenge %s", action, challenge) } @@ -516,6 +548,14 @@ func manageUploadHandler(c *gin.Context) { }) return } + user, ok := authenticatedManager(c) + if !ok { + return + } + if !userOwnsChallengeConfig(user, config) { + c.AbortWithStatusJSON(http.StatusForbidden, HTTPErrorResp{Error: "challenge management access denied"}) + return + } if err := persistUploadedChallenge(tempStageDir, config.Challenge.Metadata.Name); err != nil { status := http.StatusInternalServerError if errors.Is(err, os.ErrExist) { @@ -620,7 +660,9 @@ func persistUploadedChallenge(source, challengeName string) error { func validateFlagHandler(c *gin.Context) { flag := c.PostForm("flag") challenge_name := c.PostForm("challenge_name") - authorization := c.GetHeader("Authorization") + if !authorizeChallengeManagement(c, challenge_name, false) { + return + } challenges, err := database.QueryChallengeEntries("name", challenge_name) if err != nil { @@ -637,7 +679,7 @@ func validateFlagHandler(c *gin.Context) { return } - if msgs := manager.LogTransaction(challenge_name, "VALIDATE_FLAG: "+flag, authorization); msgs != nil { + if msgs := manager.LogTransaction(challenge_name, "VALIDATE_FLAG", c.GetHeader("Authorization")); msgs != nil { log.Warn("Error while saving transaction") } diff --git a/api/router.go b/api/router.go index 300cd3e3..b13c2c35 100644 --- a/api/router.go +++ b/api/router.go @@ -80,14 +80,14 @@ func initGinRouter() *gin.Engine { // Deploy route group manageGroup := apiGroup.Group("/manage", managerAuthorize) { - manageGroup.POST("/deploy/local/", deployLocalChallengeHandler) + manageGroup.POST("/deploy/local/", adminAuthorize, deployLocalChallengeHandler) manageGroup.POST("/challenge/", manageChallengeHandler) - manageGroup.POST("/challenge/multiple/", manageMultipleChallengeHandlerNameBased) - manageGroup.POST("/multiple/:action", manageMultipleChallengeHandlerTagBased) - manageGroup.POST("/static/:action", beastStaticContentHandler) + manageGroup.POST("/challenge/multiple/", adminAuthorize, manageMultipleChallengeHandlerNameBased) + manageGroup.POST("/multiple/:action", adminAuthorize, manageMultipleChallengeHandlerTagBased) + manageGroup.POST("/static/:action", adminAuthorize, beastStaticContentHandler) manageGroup.POST("/commit/", commitChallenge) manageGroup.POST("/challenge/verify", verifyHandler) - manageGroup.POST("/schedule/:action", manageScheduledAction) + manageGroup.POST("/schedule/:action", adminAuthorize, manageScheduledAction) manageGroup.POST("/challenge/upload", manageUploadHandler) manageGroup.POST("/challenge/validateflag", validateFlagHandler) manageGroup.GET("/logs", challengeLogsHandler) From 823773c52b7cbde9354b9bb92eb908b155241b2a Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 16:22:03 +0530 Subject: [PATCH 145/149] fix(paths): fail closed on challenge lookup Signed-off-by: vibhatsu --- core/config/challenge.go | 6 +++++- core/utils/file.go | 20 ++++++++++---------- core/utils/file_test.go | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 11 deletions(-) create mode 100644 core/utils/file_test.go diff --git a/core/config/challenge.go b/core/config/challenge.go index 57aac98e..0d37b0fb 100644 --- a/core/config/challenge.go +++ b/core/config/challenge.go @@ -24,6 +24,10 @@ var challengeNamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,63}$`) var environmentKeyPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) var generatedPathPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]{0,255}$`) +func IsValidChallengeName(name string) bool { + return challengeNamePattern.MatchString(name) +} + // This is the beast challenge config file structure // any other field specified in the file other than this structure // will be ignored. @@ -193,7 +197,7 @@ func (config *ChallengeMetadata) ValidateRequiredFields() (error, bool) { if config.Name == "" || (config.Flag == "" && !config.DynamicFlag) { return fmt.Errorf("name and flag required for the challenge"), false } - if !challengeNamePattern.MatchString(config.Name) { + if !IsValidChallengeName(config.Name) { return fmt.Errorf("challenge name must match %s", challengeNamePattern.String()), false } if config.MaxPoints > 0 && config.MinPoints > config.MaxPoints { diff --git a/core/utils/file.go b/core/utils/file.go index 06eaff08..07b5f7b0 100644 --- a/core/utils/file.go +++ b/core/utils/file.go @@ -9,24 +9,24 @@ import ( ) func GetChallengeDir(challengeName string) string { - challengeRemoteDir := "" + if config.Cfg == nil || !config.IsValidChallengeName(challengeName) { + return "" + } for _, gitRemote := range config.Cfg.GitRemotes { - if gitRemote.Active == true { - challengeRemoteDir = filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_REMOTES_DIR, + if gitRemote.Active { + challengeRemoteDir := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_REMOTES_DIR, gitRemote.RemoteName, core.BEAST_REMOTE_CHALLENGE_DIR, challengeName) - err := utils.ValidateDirExists(challengeRemoteDir) - if err == nil { + if err := utils.ValidateDirExists(challengeRemoteDir); err == nil { return challengeRemoteDir } } } - challengeRemoteDir = filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_UPLOADS_DIR, challengeName) - err := utils.ValidateDirExists(challengeRemoteDir) - if err == nil { - return challengeRemoteDir + challengeUploadDir := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_UPLOADS_DIR, challengeName) + if err := utils.ValidateDirExists(challengeUploadDir); err == nil { + return challengeUploadDir } - return challengeRemoteDir + return "" } diff --git a/core/utils/file_test.go b/core/utils/file_test.go new file mode 100644 index 00000000..2dd50b92 --- /dev/null +++ b/core/utils/file_test.go @@ -0,0 +1,35 @@ +package utils + +import ( + "os" + "path/filepath" + "testing" + + "github.com/sdslabs/beastv4/core" + "github.com/sdslabs/beastv4/core/config" +) + +func TestGetChallengeDirFailsClosed(t *testing.T) { + previousRoot := core.BEAST_GLOBAL_DIR + previousConfig := config.Cfg + core.BEAST_GLOBAL_DIR = t.TempDir() + config.Cfg = &config.BeastConfig{} + defer func() { + core.BEAST_GLOBAL_DIR = previousRoot + config.Cfg = previousConfig + }() + + for _, name := range []string{"", "missing", "../escape", "/absolute"} { + if path := GetChallengeDir(name); path != "" { + t.Fatalf("GetChallengeDir(%q) = %q, want empty", name, path) + } + } + + want := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_UPLOADS_DIR, "valid-challenge") + if err := os.MkdirAll(want, 0700); err != nil { + t.Fatal(err) + } + if got := GetChallengeDir("valid-challenge"); got != want { + t.Fatalf("GetChallengeDir() = %q, want %q", got, want) + } +} From 004d0467c68bdb78a53012a02577b0e93d94e957 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 16:24:02 +0530 Subject: [PATCH 146/149] fix(auth): revoke stale access tokens Signed-off-by: vibhatsu --- api/auth.go | 20 ++++++++++++++++++++ api/auth_middleware_test.go | 4 ++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/api/auth.go b/api/auth.go index b843e150..bbb15b82 100644 --- a/api/auth.go +++ b/api/auth.go @@ -54,6 +54,26 @@ func authorizeRoles(c *gin.Context, roles int) { c.Abort() return } + if database.Db == nil || database.DBMux == nil { + c.JSON(http.StatusServiceUnavailable, HTTPPlainResp{Message: "Authorization could not be verified"}) + c.Abort() + return + } + user, err := database.QueryFirstUserEntry("username", claims.User) + if err != nil { + status := http.StatusServiceUnavailable + if errors.Is(err, gorm.ErrRecordNotFound) { + status = http.StatusUnauthorized + } + c.JSON(status, HTTPPlainResp{Message: "Authorization could not be verified"}) + c.Abort() + return + } + if user.Status != 0 || user.Role != claims.Role { + c.JSON(http.StatusUnauthorized, HTTPPlainResp{Message: "Authorization is no longer valid"}) + c.Abort() + return + } c.Set("authClaims", claims) c.Next() } diff --git a/api/auth_middleware_test.go b/api/auth_middleware_test.go index 0672fa8a..21bc4642 100644 --- a/api/auth_middleware_test.go +++ b/api/auth_middleware_test.go @@ -38,7 +38,7 @@ func TestManagerAuthorizationRequiresVerifiedToken(t *testing.T) { request.Header.Set("Authorization", "Bearer "+token) authorized := httptest.NewRecorder() router.ServeHTTP(authorized, request) - if authorized.Code != http.StatusNoContent { - t.Fatalf("authorized status = %d, body = %s", authorized.Code, authorized.Body.String()) + if authorized.Code != http.StatusServiceUnavailable { + t.Fatalf("unbacked token status = %d, body = %s", authorized.Code, authorized.Body.String()) } } From fa6c86f9116de7d9e692d3e8f30d97fb033e2e92 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 16:31:19 +0530 Subject: [PATCH 147/149] docs(api): regenerate complete HTTPS contract Signed-off-by: vibhatsu --- Makefile | 8 +- api/README.md | 13 +- api/admin.go | 2 +- api/docs/docs.go | 2384 +++++++++++++++++++++++++++----- api/docs/swagger.json | 2396 ++++++++++++++++++++++++++++----- api/docs/swagger.yaml | 1468 +++++++++++++++++--- api/docs/swagger/swagger.json | 1850 ------------------------- api/docs/swagger/swagger.yaml | 1269 ----------------- api/exec.go | 21 +- api/exec_test.go | 4 +- api/info.go | 34 +- api/instance.go | 72 + api/main.go | 10 +- api/manage.go | 17 +- api/notification.go | 12 +- api/otp.go | 34 + api/remote.go | 4 +- api/response.go | 24 +- api/router.go | 4 +- api/status.go | 5 +- scripts/tools/swagger-docs.py | 4 +- 21 files changed, 5544 insertions(+), 4091 deletions(-) delete mode 100644 api/docs/swagger/swagger.json delete mode 100644 api/docs/swagger/swagger.yaml diff --git a/Makefile b/Makefile index e1f75dcd..e09c7b80 100644 --- a/Makefile +++ b/Makefile @@ -27,6 +27,7 @@ dev: cmdref: build @rm -rf docs/cmdref @"$(BEAST_BIN)" cmdref --reference-directory docs/cmdref + @sed -i '$${/^$$/d;}' docs/cmdref/*.md # Check go formatting check_format: @@ -59,7 +60,10 @@ requirements: @echo ">>> Building beast extras..." @./scripts/build/extras.sh -docs: +swagger: + @$(GO) run github.com/swaggo/swag/cmd/swag@v1.16.4 init --generalInfo main.go --dir api --output api/docs --parseDependency + +docs: swagger @rm -rf site/ @echo ">>> Building Documentation" @mkdocs build --strict @@ -69,4 +73,4 @@ installenv: @echo 'Setting up environment for beast.' @./scripts/installenv.sh -.PHONY: build cmdref format test test-race integration-test check_format docs installenv govet requirements +.PHONY: build cmdref format test test-race integration-test check_format swagger docs installenv govet requirements diff --git a/api/README.md b/api/README.md index bee8201b..2204edc7 100644 --- a/api/README.md +++ b/api/README.md @@ -1,13 +1,10 @@ -# API interface for beast +# Beast API documentation -To build docs for beast api interface, make sure you have `swag` installed +Regenerate the embedded Swagger contract with the pinned module version: ```bash -$ go get -u github.com/swaggo/swag/cmd/swag +go run github.com/swaggo/swag/cmd/swag@v1.16.4 init \ + --generalInfo main.go --dir api --output api/docs --parseDependency ``` -To build docs make sure you are in `$BEAST_ROOT/api/` - -``` -$ swag init -``` +Commit `api/docs/docs.go`, `swagger.json`, and `swagger.yaml` together with annotation changes. The running HTTPS server publishes the result at `/api/docs/index.html`. diff --git a/api/admin.go b/api/admin.go index 0b6fc7e8..34b75dbf 100644 --- a/api/admin.go +++ b/api/admin.go @@ -23,7 +23,7 @@ import ( // @Success 200 {object} api.ChallengeStatusResp // @Failure 400 {object} api.HTTPPlainResp // @Failure 500 {object} api.HTTPPlainResp -// @Router /api/admin/users/:action/:id [post] +// @Router /api/admin/users/{action}/{id} [post] func userActionHandler(c *gin.Context) { action := c.Param("action") userId := c.Param("id") diff --git a/api/docs/docs.go b/api/docs/docs.go index 2643c42a..b8fd3e15 100644 --- a/api/docs/docs.go +++ b/api/docs/docs.go @@ -1,40 +1,31 @@ -// GENERATED BY THE COMMAND ABOVE; DO NOT EDIT -// This file was generated by swaggo/swag - +// Package docs Code generated by swaggo/swag. DO NOT EDIT package docs -import ( - "bytes" - "encoding/json" - "strings" - - "github.com/alecthomas/template" - "github.com/swaggo/swag" -) +import "github.com/swaggo/swag" -var doc = `{ +const docTemplate = `{ "schemes": {{ marshal .Schemes }}, "swagger": "2.0", "info": { - "description": "{{.Description}}", + "description": "{{escape .Description}}", "title": "{{.Title}}", "contact": { "name": "SDSLabs", "url": "https://chat.sdslabs.co", - "email": "contact.sdslabs.co.in" + "email": "contact@sdslabs.co.in" }, "license": { "name": "Apache 2.0", - "url": "http://www.apache.org/licenses/LICENSE-2.0.html" + "url": "https://www.apache.org/licenses/LICENSE-2.0.html" }, "version": "{{.Version}}" }, "host": "{{.Host}}", "basePath": "{{.BasePath}}", "paths": { - "/api/admin/statistics": { - "get": { - "description": "returns various information about the competition which are used to control competition", + "/api/admin/freezeLeaderboard": { + "post": { + "description": "freezes the user leaderboard on demand.", "consumes": [ "application/json" ], @@ -44,7 +35,7 @@ var doc = `{ "tags": [ "info" ], - "summary": "returns competition info", + "summary": "Freeze user leaderboard", "parameters": [ { "type": "string", @@ -58,7 +49,7 @@ var doc = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/api.CompetitionInfoResp" + "$ref": "#/definitions/api.UserResp" } }, "400": { @@ -66,54 +57,69 @@ var doc = `{ "schema": { "$ref": "#/definitions/api.HTTPErrorResp" } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.HTTPErrorResp" + } } } } }, - "/api/admin/users/:action/:id": { - "post": { - "description": "Ban/unban a user based on his user id. This operation can only be done by admins", - "consumes": [ + "/api/admin/instances": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ "application/json" ], + "tags": [ + "admin" + ], + "summary": "List all active instances", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/api.AdminInstanceResponse" + } + } + } + } + } + }, + "/api/admin/instances/challenge/{challenge_name}": { + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], "produces": [ "application/json" ], "tags": [ "admin" ], - "summary": "Ban/Unban a user based on his id and the action provided.", + "summary": "Delete all instances for a challenge", "parameters": [ { "type": "string", - "description": "Action to perform ban/unban", - "name": "action", - "in": "query", - "required": true - }, - { - "type": "string", - "description": "Id of user", - "name": "id", - "in": "query", + "description": "Challenge name", + "name": "challenge_name", + "in": "path", "required": true } ], "responses": { "200": { "description": "OK", - "schema": { - "$ref": "#/definitions/api.ChallengeStatusResp" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/api.HTTPPlainResp" - } - }, - "500": { - "description": "Internal Server Error", "schema": { "$ref": "#/definitions/api.HTTPPlainResp" } @@ -121,66 +127,27 @@ var doc = `{ } } }, - "/api/config/competition-info": { - "post": { - "description": "Populates beast gobal config map by reparsing the config file $HOME/.beast/config.toml.", - "consumes": [ - "application/json" + "/api/admin/instances/user/{user_id}": { + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } ], "produces": [ "application/json" ], "tags": [ - "config" + "admin" ], - "summary": "Updates competition info in the beast global configuration, located at ~/.beast/config.toml.", + "summary": "Delete all instances owned by a user", "parameters": [ { "type": "string", - "description": "Competition Name", - "name": "name", - "in": "formData", - "required": true - }, - { - "type": "string", - "description": "Some information about competition", - "name": "about", - "in": "formData", - "required": true - }, - { - "type": "string", - "description": "Competitions Prizes for the winners", - "name": "prizes", - "in": "formData" - }, - { - "type": "string", - "description": "Competition's starting time", - "name": "starting_time", - "in": "formData", - "required": true - }, - { - "type": "string", - "description": "Competition's ending time", - "name": "ending_time", - "in": "formData", - "required": true - }, - { - "type": "string", - "description": "Competition's timezone", - "name": "timezone", - "in": "formData", + "description": "User ID", + "name": "user_id", + "in": "path", "required": true - }, - { - "type": "file", - "description": "Competition's logo", - "name": "logo", - "in": "formData" } ], "responses": { @@ -189,41 +156,30 @@ var doc = `{ "schema": { "$ref": "#/definitions/api.HTTPPlainResp" } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/api.HTTPPlainResp" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/api.HTTPErrorResp" - } } } } }, - "/api/config/reload/": { - "patch": { - "description": "Populates beast gobal config map by reparsing the config file $HOME/.beast/config.toml.", - "consumes": [ - "application/json" + "/api/admin/instances/{instance_id}": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } ], "produces": [ "application/json" ], "tags": [ - "config" + "admin" ], - "summary": "Reloads any changes in beast global configuration, located at ~/.beast/config.toml.", + "summary": "Get an instance by ID", "parameters": [ { "type": "string", - "description": "Bearer", - "name": "Authorization", - "in": "header", + "description": "Instance ID", + "name": "instance_id", + "in": "path", "required": true } ], @@ -231,11 +187,36 @@ var doc = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/api.HTTPPlainResp" + "$ref": "#/definitions/api.AdminInstanceResponse" } - }, - "400": { - "description": "Bad Request", + } + } + }, + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Delete an instance by ID", + "parameters": [ + { + "type": "string", + "description": "Instance ID", + "name": "instance_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", "schema": { "$ref": "#/definitions/api.HTTPPlainResp" } @@ -243,9 +224,9 @@ var doc = `{ } } }, - "/api/info/challenge/info": { + "/api/admin/leaderboard": { "get": { - "description": "Returns all information about the challenges by the challenge name.", + "description": "Returns admin leaderboard of all users", "consumes": [ "application/json" ], @@ -255,7 +236,7 @@ var doc = `{ "tags": [ "info" ], - "summary": "Returns all information about the challenges.", + "summary": "Returns admin leaderboard", "parameters": [ { "type": "string", @@ -266,17 +247,16 @@ var doc = `{ }, { "type": "string", - "description": "Name of challenge", - "name": "name", - "in": "query", - "required": true + "description": "Page number", + "name": "page", + "in": "query" } ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/api.ChallengeInfoResp" + "$ref": "#/definitions/api.UserResp" } }, "400": { @@ -285,12 +265,6 @@ var doc = `{ "$ref": "#/definitions/api.HTTPErrorResp" } }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/api.HTTPErrorResp" - } - }, "500": { "description": "Internal Server Error", "schema": { @@ -300,9 +274,9 @@ var doc = `{ } } }, - "/api/info/challenges": { + "/api/admin/statistics": { "get": { - "description": "Returns information about all the challenges present in the database with and without filters.", + "description": "returns statistics of users in competition (currently limited to ban/unban status of users)", "consumes": [ "application/json" ], @@ -312,20 +286,8 @@ var doc = `{ "tags": [ "info" ], - "summary": "Returns information about all challenges with and without filters.", + "summary": "statistics of users in competition", "parameters": [ - { - "type": "string", - "description": "Filter parameter by which challenges are filtered", - "name": "filter", - "in": "query" - }, - { - "type": "string", - "description": "Value of filtered parameter", - "name": "value", - "in": "query" - }, { "type": "string", "description": "Bearer", @@ -338,11 +300,11 @@ var doc = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/api.ChallengeInfoResp" + "$ref": "#/definitions/api.UsersStatisticsResp" } }, - "400": { - "description": "Bad Request", + "404": { + "description": "Not Found", "schema": { "$ref": "#/definitions/api.HTTPErrorResp" } @@ -356,9 +318,9 @@ var doc = `{ } } }, - "/api/info/images/available": { + "/api/admin/submissions": { "get": { - "description": "Returns all the available base images which can be used for challenge creation as the base OS for challenge.", + "description": "Handles submissions made by the user", "consumes": [ "application/json" ], @@ -368,7 +330,7 @@ var doc = `{ "tags": [ "info" ], - "summary": "Gives all the base images that can be used while creating a beast challenge, this is a constant specified in beast global config", + "summary": "Handles submissions made by the user", "parameters": [ { "type": "string", @@ -382,15 +344,21 @@ var doc = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/api.AvailableImagesResp" + "$ref": "#/definitions/api.SubmissionResp" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.HTTPErrorResp" } } } } }, - "/api/info/logs": { - "get": { - "description": "Gives container logs for a particular challenge, useful for debugging purposes.", + "/api/admin/unfreezeLeaderboard": { + "post": { + "description": "unfreezes the user leaderboard on demand.", "consumes": [ "application/json" ], @@ -400,7 +368,7 @@ var doc = `{ "tags": [ "info" ], - "summary": "Handles route related to logs handling of container", + "summary": "Unfreeze user leaderboard", "parameters": [ { "type": "string", @@ -408,39 +376,33 @@ var doc = `{ "name": "Authorization", "in": "header", "required": true - }, - { - "type": "string", - "description": "The name of the challenge to get the logs for.", - "name": "challenge", - "in": "query" } ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/api.LogsInfoResp" + "$ref": "#/definitions/api.UserResp" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/api.HTTPPlainResp" + "$ref": "#/definitions/api.HTTPErrorResp" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/api.HTTPPlainResp" + "$ref": "#/definitions/api.HTTPErrorResp" } } } } }, - "/api/info/ports/used": { - "get": { - "description": "Returns the ports in use by beast, which cannot be used in creating a new challenge..", + "/api/admin/users/{action}/{id}": { + "post": { + "description": "Ban/Unban/Hide/Unhide a user based on his user id. This operation can only be done by admins", "consumes": [ "application/json" ], @@ -448,15 +410,22 @@ var doc = `{ "application/json" ], "tags": [ - "info" + "admin" ], - "summary": "Returns ports in use by beast by looking in the hack git repository, also returns min and max value of port allowed while specifying in beast challenge config.", + "summary": "Ban/Unban/Hide/Unhide a user based on his id and the action provided.", "parameters": [ { "type": "string", - "description": "Bearer", - "name": "Authorization", - "in": "header", + "description": "Action to perform Ban/Unban/Hide/Unhide", + "name": "action", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "Id of user", + "name": "id", + "in": "query", "required": true } ], @@ -464,15 +433,27 @@ var doc = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/api.PortsInUseResp" + "$ref": "#/definitions/api.ChallengeStatusResp" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.HTTPPlainResp" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.HTTPPlainResp" } } } } }, - "/api/info/submissions": { - "get": { - "description": "Handles submissions made by the user", + "/api/config/challenge-info": { + "post": { + "description": "Updates challenge info in the database, located at ~/.beast/beast.db.", "consumes": [ "application/json" ], @@ -480,93 +461,1052 @@ var doc = `{ "application/json" ], "tags": [ - "info" + "config" ], - "summary": "Handles submissions made by the user", + "summary": "Updates challenge info in the database, located at ~/.beast/beast.db.", "parameters": [ { "type": "string", - "description": "Bearer", - "name": "Authorization", - "in": "header", + "description": "Challenge Name", + "name": "name", + "in": "formData", "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/api.SubmissionResp" - } }, - "500": { - "description": "Internal Server Error", + { + "type": "string", + "description": "Challenge's description", + "name": "desc", + "in": "formData" + }, + { + "type": "string", + "description": "Challenge's points", + "name": "points", + "in": "formData" + }, + { + "type": "string", + "description": "Challenge's flag", + "name": "flag", + "in": "formData" + }, + { + "type": "string", + "description": "Challenge's tags", + "name": "tags", + "in": "formData" + }, + { + "type": "string", + "description": "Challenge's ports", + "name": "ports", + "in": "formData" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.HTTPPlainResp" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.HTTPPlainResp" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.HTTPErrorResp" + } + } + } + } + }, + "/api/info/challenge/{name}": { + "get": { + "description": "Returns all information about the challenges by the challenge name.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "info" + ], + "summary": "Returns all information about the challenges.", + "parameters": [ + { + "type": "string", + "description": "Bearer", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Name of challenge", + "name": "name", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.Challenge" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.HTTPErrorResp" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.HTTPErrorResp" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.HTTPErrorResp" + } + } + } + } + }, + "/api/info/challenges": { + "get": { + "description": "Returns information about all the challenges present in the database with and without filters.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "info" + ], + "summary": "Returns metadata about all challenges with and without filters.", + "parameters": [ + { + "type": "string", + "description": "Filter parameter by which challenges are filtered", + "name": "filter", + "in": "query" + }, + { + "type": "string", + "description": "Value of filtered parameter", + "name": "value", + "in": "query" + }, + { + "type": "string", + "description": "Bearer", + "name": "Authorization", + "in": "header", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/api.ChallengeMetadata" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.HTTPErrorResp" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.HTTPErrorResp" + } + } + } + } + }, + "/api/info/competition-info": { + "get": { + "description": "returns various information about the competition which are used to control competition", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "info" + ], + "summary": "returns competition info", + "parameters": [ + { + "type": "string", + "description": "Bearer", + "name": "Authorization", + "in": "header", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.CompetitionInfoResp" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.HTTPErrorResp" + } + } + } + } + }, + "/api/info/download": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "info" + ], + "parameters": [ + { + "type": "string", + "description": "Bearer", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "The name of the challenge to get the logs for.", + "name": "challenge", + "in": "query" + }, + { + "type": "string", + "description": "The name of the static asset requested.", + "name": "asset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.LogsInfoResp" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.HTTPPlainResp" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.HTTPPlainResp" + } + } + } + } + }, + "/api/info/hint/{hintID}": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "info" + ], + "summary": "Read or purchase a challenge hint", + "parameters": [ + { + "type": "integer", + "description": "Hint ID", + "name": "hintID", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.HintResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.HTTPErrorResp" + } + } + } + }, + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "info" + ], + "summary": "Read or purchase a challenge hint", + "parameters": [ + { + "type": "integer", + "description": "Hint ID", + "name": "hintID", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.HintResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.HTTPErrorResp" + } + } + } + } + }, + "/api/info/images/available": { + "get": { + "description": "Returns all the available base images which can be used for challenge creation as the base OS for challenge.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "info" + ], + "summary": "Gives all the base images that can be used while creating a beast challenge, this is a constant specified in beast global config", + "parameters": [ + { + "type": "string", + "description": "Bearer", + "name": "Authorization", + "in": "header", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.AvailableImagesResp" + } + } + } + } + }, + "/api/info/leaderboard": { + "get": { + "description": "Returns leaderboard of all users", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "info" + ], + "summary": "Returns leaderboard", + "parameters": [ + { + "type": "string", + "description": "Bearer", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Page number", + "name": "page", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.UserResp" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.HTTPErrorResp" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.HTTPErrorResp" + } + } + } + } + }, + "/api/info/leaderboard-graph": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "info" + ], + "summary": "Return leaderboard score history", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/database.UserLeaderboardResp" + } + } + } + } + } + }, + "/api/info/submissions/challenge/{challenge_id}": { + "get": { + "description": "Returns all user attempts for a given challenge.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "info" + ], + "summary": "Get challenge attempts", + "parameters": [ + { + "type": "integer", + "description": "Challenge ID", + "name": "challenge_id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Bearer", + "name": "Authorization", + "in": "header", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/api.UserSolveResp" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.HTTPErrorResp" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.HTTPErrorResp" + } + } + } + } + }, + "/api/info/submissions/user/{user_id}": { + "get": { + "description": "Returns all submissions for a specific user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "info" + ], + "summary": "Get submissions by user", + "parameters": [ + { + "type": "integer", + "description": "User ID", + "name": "user_id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Bearer", + "name": "Authorization", + "in": "header", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/api.SubmissionResp" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.HTTPErrorResp" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.HTTPErrorResp" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.HTTPErrorResp" + } + } + } + } + }, + "/api/info/tags": { + "get": { + "description": "returns all unique tags", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "info" + ], + "summary": "returns all tags", + "parameters": [ + { + "type": "string", + "description": "Bearer", + "name": "Authorization", + "in": "header", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.TagInfoResp" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.HTTPErrorResp" + } + } + } + } + }, + "/api/info/user/{username}": { + "get": { + "description": "Returns user info based on userId", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "info" + ], + "summary": "Returns user info", + "parameters": [ + { + "type": "string", + "description": "Bearer", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "type": "integer", + "description": "User ID", + "name": "user_id", + "in": "query" + }, + { + "type": "string", + "description": "Username", + "name": "username", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.UserResp" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.HTTPErrorResp" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.HTTPErrorResp" + } + } + } + } + }, + "/api/info/usercount": { + "get": { + "description": "Returns the number of users in the database with role=contestant", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "info" + ], + "summary": "Returns the number of users in the database with role=contestant", + "parameters": [ + { + "type": "string", + "description": "Bearer", + "name": "Authorization", + "in": "header", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.UserCountResp" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.HTTPErrorResp" + } + } + } + } + }, + "/api/info/users": { + "get": { + "description": "Returns all available user's info", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "info" + ], + "summary": "Returns all user's info", + "parameters": [ + { + "type": "string", + "description": "Bearer", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Sort by username or score", + "name": "sort", + "in": "query" + }, + { + "type": "string", + "description": "Score order: asc or desc", + "name": "order", + "in": "query" + }, + { + "type": "string", + "description": "Filter by banned, active, or hidden", + "name": "filter", + "in": "query" + }, + { + "type": "string", + "description": "Response format: json or csv", + "name": "format", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/api.UsersResp" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.HTTPErrorResp" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.HTTPErrorResp" + } + } + } + } + }, + "/api/instances": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "instances" + ], + "summary": "List the current user's instances", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/api.InstanceResponse" + } + } + } + } + } + }, + "/api/instances/{challenge_name}": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "instances" + ], + "summary": "Get the current user's challenge instance", + "parameters": [ + { + "type": "string", + "description": "Challenge name", + "name": "challenge_name", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.InstanceResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.HTTPErrorResp" + } + } + } + }, + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "instances" + ], + "summary": "Delete the current user's challenge instance", + "parameters": [ + { + "type": "string", + "description": "Challenge name", + "name": "challenge_name", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.HTTPPlainResp" + } + } + } + } + }, + "/api/instances/{challenge_name}/extend": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "instances" + ], + "summary": "Extend the current user's challenge instance", + "parameters": [ + { + "type": "string", + "description": "Challenge name", + "name": "challenge_name", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Requested extension in seconds", + "name": "seconds", + "in": "formData" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.InstanceResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.HTTPErrorResp" + } + } + } + } + }, + "/api/instances/{challenge_name}/spawn": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "instances" + ], + "summary": "Spawn a per-user challenge instance", + "parameters": [ + { + "type": "string", + "description": "Challenge name", + "name": "challenge_name", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.InstanceResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.HTTPErrorResp" + } + } + } + } + }, + "/api/manage/challenge/": { + "post": { + "description": "Handles challenge management routes with actions which includes - DEPLOY, UNDEPLOY, PURGE.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "manage" + ], + "summary": "Handles challenge management actions.", + "parameters": [ + { + "type": "string", + "description": "Name of the challenge to be managed, here name is the unique identifier for challenge", + "name": "name", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "Action for the challenge", + "name": "action", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", "schema": { - "$ref": "#/definitions/api.HTTPErrorResp" + "$ref": "#/definitions/api.HTTPPlainResp" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.HTTPPlainResp" } } } } }, - "/api/info/user": { - "get": { - "description": "Returns user info based on userId", + "/api/manage/challenge/multiple/": { + "post": { + "description": "Handles challenge management routes with actions which includes - DEPLOY, UNDEPLOY, PURGE of multiple challenges.", "consumes": [ "application/json" ], "produces": [ "application/json" ], - "tags": [ - "info" - ], - "summary": "Returns user info", + "summary": "Handles multiple challenge management actions.", "parameters": [ { "type": "string", - "description": "Bearer", - "name": "Authorization", - "in": "header", + "description": "Name of the challenge to be managed, here name is the unique identifier for challenges seperated by a comma", + "name": "name", + "in": "query", "required": true }, { "type": "string", - "description": "User's id", - "name": "value", - "in": "formData" - }, - { - "type": "string", - "description": "username", - "name": "value", - "in": "query" + "description": "Action for the challenge", + "name": "action", + "in": "query", + "required": true } ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/api.UserResp" + "$ref": "#/definitions/api.HTTPPlainResp" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/api.HTTPErrorResp" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/api.HTTPErrorResp" + "$ref": "#/definitions/api.HTTPPlainResp" } } } } }, - "/api/info/user/available": { - "get": { - "description": "Returns all available user's info", + "/api/manage/challenge/upload": { + "post": { + "description": "Handles the challenge management from a challenge in zip file. Currently prepare the zip file", "consumes": [ "application/json" ], @@ -574,15 +1514,15 @@ var doc = `{ "application/json" ], "tags": [ - "info" + "manage" ], - "summary": "Returns all user's info", + "summary": "Unzip and fetch info from beast.toml file in challenge", "parameters": [ { - "type": "string", - "description": "Bearer", - "name": "Authorization", - "in": "header", + "type": "file", + "description": ".zip file to be uploaded to fetch challenge info", + "name": "file", + "in": "formData", "required": true } ], @@ -590,11 +1530,11 @@ var doc = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/api.UserResp" + "$ref": "#/definitions/api.ChallengePreviewResp" } }, - "404": { - "description": "Not Found", + "400": { + "description": "Bad Request", "schema": { "$ref": "#/definitions/api.HTTPErrorResp" } @@ -608,11 +1548,12 @@ var doc = `{ } } }, - "/api/manage/challenge/": { + "/api/manage/challenge/validateflag": { "post": { - "description": "Handles challenge management routes with actions which includes - DEPLOY, UNDEPLOY, PURGE.", - "consumes": [ - "application/json" + "security": [ + { + "ApiKeyAuth": [] + } ], "produces": [ "application/json" @@ -620,19 +1561,57 @@ var doc = `{ "tags": [ "manage" ], - "summary": "Handles challenge management actions.", + "summary": "Validate a configured challenge flag as its manager", "parameters": [ { "type": "string", - "description": "Name of the challenge to be managed, here name is the unique identifier for challenge", - "name": "name", - "in": "query", + "description": "Challenge name", + "name": "challenge_name", + "in": "formData", "required": true }, { "type": "string", - "description": "Action for the challenge", - "name": "action", + "description": "Flag to validate", + "name": "flag", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.HTTPPlainResp" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/api.HTTPErrorResp" + } + } + } + } + }, + "/api/manage/challenge/verify": { + "post": { + "description": "Commits the challenge container for later use", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "manage" + ], + "summary": "Commits the challenge container so that later the challenge image can be used deployment", + "parameters": [ + { + "type": "string", + "description": "Name of the challenge to commit", + "name": "challenge", "in": "query", "required": true } @@ -644,8 +1623,8 @@ var doc = `{ "$ref": "#/definitions/api.HTTPPlainResp" } }, - "400": { - "description": "Bad Request", + "500": { + "description": "Internal Server Error", "schema": { "$ref": "#/definitions/api.HTTPPlainResp" } @@ -653,9 +1632,13 @@ var doc = `{ } } }, - "/api/manage/challenge/upload": { + "/api/manage/challenge/{name}/exec": { "post": { - "description": "Handles the challenge management from a challenge in tar file. Currently prepare the tar file", + "security": [ + { + "ApiKeyAuth": [] + } + ], "consumes": [ "application/json" ], @@ -665,21 +1648,30 @@ var doc = `{ "tags": [ "manage" ], - "summary": "Untar and fetch info from beast.toml file in challenge", + "summary": "Execute an argument-vector command in an owned challenge container", "parameters": [ { - "type": "file", - "description": ".tar file to be uploaded to fetch challenge info", - "name": "file", - "in": "formData", + "type": "string", + "description": "Challenge name", + "name": "name", + "in": "path", "required": true + }, + { + "description": "Bounded exec request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/api.ExecChallengeRequest" + } } ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/api.ChallengePreviewResp" + "$ref": "#/definitions/api.ExecChallengeResponse" } }, "400": { @@ -688,8 +1680,8 @@ var doc = `{ "$ref": "#/definitions/api.HTTPErrorResp" } }, - "500": { - "description": "Internal Server Error", + "403": { + "description": "Forbidden", "schema": { "$ref": "#/definitions/api.HTTPErrorResp" } @@ -729,7 +1721,7 @@ var doc = `{ } } }, - "/api/manage/deploy/local": { + "/api/manage/deploy/local/": { "post": { "description": "Handles deployment of a challenge using the absolute directory path", "consumes": [ @@ -773,7 +1765,62 @@ var doc = `{ } } }, - "/api/manage/multiple/:action": { + "/api/manage/logs": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Gives container logs for a particular challenge, useful for debugging purposes.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "info" + ], + "summary": "Handles route related to logs handling of container", + "parameters": [ + { + "type": "string", + "description": "Bearer", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "The name of the challenge to get the logs for.", + "name": "challenge", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.LogsInfoResp" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.HTTPPlainResp" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.HTTPPlainResp" + } + } + } + } + }, + "/api/manage/multiple/{action}": { "post": { "description": "Handles challenge management routes for multiple the challenges with actions which includes - DEPLOY, UNDEPLOY.", "consumes": [ @@ -817,7 +1864,7 @@ var doc = `{ } } }, - "/api/manage/schedule/:action": { + "/api/manage/schedule/{action}": { "post": { "description": "Handles scheduleing of challenge action to executed at some later point of time", "consumes": [ @@ -886,7 +1933,7 @@ var doc = `{ } } }, - "/api/manage/static/:action": { + "/api/manage/static/{action}": { "post": { "description": "Handles beast static content serving container routes.", "consumes": [ @@ -976,7 +2023,7 @@ var doc = `{ } }, "/api/notification/available": { - "post": { + "get": { "description": "Fetch all the notifications from database", "consumes": [ "application/json" @@ -1005,7 +2052,7 @@ var doc = `{ } }, "/api/notification/delete": { - "post": { + "delete": { "description": "Removes notifications", "consumes": [ "application/json" @@ -1048,8 +2095,32 @@ var doc = `{ } } }, + "/api/notification/stream": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ + "text/event-stream" + ], + "tags": [ + "notification" + ], + "summary": "Stream server-sent notifications", + "responses": { + "200": { + "description": "SSE stream", + "schema": { + "type": "string" + } + } + } + } + }, "/api/notification/update": { - "post": { + "put": { "description": "Updates any changes in the notifications", "consumes": [ "application/json" @@ -1106,7 +2177,7 @@ var doc = `{ } } }, - "/api/remote/reset/": { + "/api/remote/reset": { "post": { "description": "Resets local copy of remote git directory, it first deletes the existing directory and then clone from the remote again.", "consumes": [ @@ -1144,7 +2215,7 @@ var doc = `{ } } }, - "/api/remote/sync/": { + "/api/remote/sync": { "post": { "description": "Syncs beasts local challenges database with the remote git repository(hack) the local copy of the challenge database is located at $HOME/.beast/remote/$REMOTE_NAME.", "consumes": [ @@ -1182,7 +2253,61 @@ var doc = `{ } } }, - "/api/status/all/:filter": { + "/api/status/all": { + "get": { + "description": "This returns the challenges in the status provided, along with their name and last updated time.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "status" + ], + "summary": "Returns challenge deployment status from the beast database for the challenges which matches the stauts according to filter.", + "parameters": [ + { + "type": "string", + "description": "Status type to filter with, if none specified then all", + "name": "filter", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "Bearer", + "name": "Authorization", + "in": "header", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/api.ChallengeStatusResp" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.HTTPPlainResp" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.HTTPPlainResp" + } + } + } + } + }, + "/api/status/all/{filter}": { "get": { "description": "This returns the challenges in the status provided, along with their name and last updated time.", "consumes": [ @@ -1236,7 +2361,7 @@ var doc = `{ } } }, - "/api/status/challenge/:name": { + "/api/status/challenge/{name}": { "get": { "description": "Returns challenge deployment status from the beast database, for those challenges which are not present a status value NA is returned.", "consumes": [ @@ -1438,12 +2563,6 @@ var doc = `{ "name": "email", "in": "formData", "required": true - }, - { - "type": "string", - "description": "User's ssh-key", - "name": "ssh-key", - "in": "formData" } ], "responses": { @@ -1508,9 +2627,209 @@ var doc = `{ } } } + }, + "/auth/send-otp": { + "post": { + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Send an email verification OTP", + "parameters": [ + { + "type": "string", + "description": "Email address", + "name": "email", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.HTTPPlainResp" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.HTTPErrorResp" + } + } + } + } + }, + "/auth/send-otp-forget": { + "post": { + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Send a password-reset OTP", + "parameters": [ + { + "type": "string", + "description": "Registered email address", + "name": "email", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.HTTPPlainResp" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.HTTPErrorResp" + } + } + } + } + }, + "/auth/verify-otp": { + "post": { + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Verify an email OTP", + "parameters": [ + { + "type": "string", + "description": "Email address", + "name": "email", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "One-time code", + "name": "otp", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.HTTPPlainResp" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.HTTPErrorResp" + } + } + } + } + }, + "/auth/verify-otp-forget": { + "post": { + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Verify a password-reset OTP", + "parameters": [ + { + "type": "string", + "description": "Registered email address", + "name": "email", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "One-time code", + "name": "otp", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.HTTPAuthorizeResp" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.HTTPErrorResp" + } + } + } + } } }, "definitions": { + "api.AdminInstanceResponse": { + "type": "object", + "properties": { + "challenge_name": { + "type": "string" + }, + "container_id": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "deployment_type": { + "type": "string" + }, + "expires_at": { + "type": "string" + }, + "hosted_address": { + "type": "string" + }, + "instance_id": { + "type": "string" + }, + "port": { + "type": "integer" + }, + "ttl_seconds": { + "type": "integer" + }, + "user_id": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, "api.AvailableImagesResp": { "type": "object", "properties": { @@ -1520,8 +2839,8 @@ var doc = `{ "type": "string" }, "example": [ - "['ubuntu16.04'", - " 'ubuntu18.04']" + "ubuntu:24.04", + "debian:bookworm" ] }, "message": { @@ -1530,9 +2849,29 @@ var doc = `{ } } }, - "api.ChallengeInfoResp": { + "api.Challenge": { "type": "object", "properties": { + "additionalLinks": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "https://link1.example", + "https://link2.example" + ] + }, + "assets": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "image1.png", + "zippy.zip" + ] + }, "category": { "type": "string", "example": "web" @@ -1540,14 +2879,111 @@ var doc = `{ "createdAt": { "type": "string" }, + "deployedLink": { + "type": "string", + "example": "beast.sdslabs.co or ip:port" + }, + "deployedStatus": { + "type": "string", + "example": "deployed" + }, "description": { - "type": "string" + "type": "string", + "example": "A simple web challenge" + }, + "difficulty": { + "type": "string", + "example": "easy" }, "hints": { + "type": "array", + "items": { + "$ref": "#/definitions/api.HintInfo" + } + }, + "id": { + "type": "integer", + "example": 0 + }, + "instanceExpiration": { + "type": "integer", + "example": 300 + }, + "instanced": { + "type": "boolean", + "example": false + }, + "maxAttemptLimit": { + "type": "integer", + "example": 5 + }, + "name": { + "type": "string", + "example": "Web Challenge" + }, + "points": { + "type": "integer", + "example": 50 + }, + "preRequisite": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "chall1", + "chall2" + ] + }, + "previousTries": { + "type": "integer", + "example": 3 + }, + "solveStatus": { + "type": "boolean", + "example": true + }, + "solvesNumber": { + "type": "integer", + "example": 100 + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "pwn", + "misc" + ] + } + } + }, + "api.ChallengeMetadata": { + "type": "object", + "properties": { + "createdAt": { "type": "string" }, + "deployedStatus": { + "type": "string", + "example": "deployed" + }, + "difficulty": { + "type": "string", + "example": "easy" + }, "id": { - "type": "integer" + "type": "integer", + "example": 0 + }, + "instanceExpiration": { + "type": "integer", + "example": 300 + }, + "instanced": { + "type": "boolean", + "example": false }, "name": { "type": "string", @@ -1557,43 +2993,74 @@ var doc = `{ "type": "integer", "example": 50 }, - "ports": { + "preRequisite": { "type": "array", "items": { - "type": "integer" - } + "type": "string" + }, + "example": [ + "chall1", + "chall2" + ] }, - "solves": { - "type": "array", - "items": { - "$ref": "#/definitions/api.UserSolveResp" - } + "solveStatus": { + "type": "boolean", + "example": true }, "solvesNumber": { "type": "integer", "example": 100 }, - "status": { - "type": "string", - "example": "deployed" + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "pwn", + "misc" + ] } } }, "api.ChallengePreviewResp": { "type": "object", "properties": { + "additionalLinks": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "https://link1.example", + "https://link2.example" + ] + }, + "assets": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "image1.png", + "zippy.zip" + ] + }, "category": { "type": "string", "example": "web" }, + "deployedLink": { + "type": "string", + "example": "beast.sdslabs.co" + }, "description": { - "type": "string" + "type": "string", + "example": "A simple web challenge" }, - "hints": { - "type": "array", - "items": { - "type": "string" - } + "maxAttemptLimit": { + "type": "integer", + "example": 5 }, "name": { "type": "string", @@ -1607,7 +3074,31 @@ var doc = `{ "type": "array", "items": { "type": "integer" - } + }, + "example": [ + 3001, + 3002 + ] + }, + "preRequisite": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "web-php", + "simple" + ] + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "pwn", + "misc" + ] } } }, @@ -1616,7 +3107,7 @@ var doc = `{ "properties": { "category": { "type": "string", - "example": "web" + "example": "bare" }, "id": { "type": "integer", @@ -1632,6 +3123,16 @@ var doc = `{ }, "solvedAt": { "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "pwn", + "misc" + ] } } }, @@ -1677,7 +3178,42 @@ var doc = `{ "type": "string" }, "timezone": { + "type": "string", + "example": "Asia/Calcutta: UTC +05:30" + } + } + }, + "api.ExecChallengeRequest": { + "type": "object", + "properties": { + "command": { + "type": "array", + "items": { + "type": "string" + } + }, + "instance_id": { + "type": "string" + }, + "timeout_seconds": { + "type": "integer" + } + } + }, + "api.ExecChallengeResponse": { + "type": "object", + "properties": { + "exit_code": { + "type": "integer" + }, + "stderr": { + "type": "string" + }, + "stdout": { "type": "string" + }, + "truncated": { + "type": "boolean" } } }, @@ -1716,60 +3252,95 @@ var doc = `{ } } }, - "api.LogsInfoResp": { + "api.HintInfo": { "type": "object", "properties": { - "stderr": { - "type": "string", - "example": "[ERROR] Challenge deployment failed." + "id": { + "type": "integer" }, - "stdout": { - "type": "string", - "example": "[INFO] Challenge is starting to deploy" + "points": { + "type": "integer" } } }, - "api.PortsInUseResp": { + "api.HintResponse": { "type": "object", "properties": { - "port_max_value": { - "type": "integer", - "example": 20000 + "description": { + "type": "string", + "example": "This is a hint" }, - "port_min_value": { + "points": { "type": "integer", - "example": 10000 + "example": 10 + } + } + }, + "api.InstanceResponse": { + "type": "object", + "properties": { + "challenge_name": { + "type": "string" }, - "ports_in_use": { - "type": "array", - "items": { - "type": "integer" - } + "created_at": { + "type": "string" + }, + "expires_at": { + "type": "string" + }, + "hosted_address": { + "type": "string" + }, + "instance_id": { + "type": "string" + }, + "port": { + "type": "integer" + }, + "ttl_seconds": { + "type": "integer" } } }, - "api.SubmissionResp": { + "api.LogsInfoResp": { "type": "object", "properties": { - "category": { + "stderr": { "type": "string", - "example": "web" + "example": "[ERROR] Challenge deployment failed." }, + "stdout": { + "type": "string", + "example": "[INFO] Challenge is starting to deploy" + } + } + }, + "api.SubmissionResp": { + "type": "object", + "properties": { "chall_id": { "type": "integer", "example": 3 }, + "cheating": { + "type": "boolean", + "example": false + }, + "flag": { + "type": "string", + "example": "flag{@#$}" + }, "name": { "type": "string", "example": "Web Challenge" }, - "points": { - "type": "integer", - "example": 50 - }, "solvedAt": { "type": "string" }, + "success": { + "type": "boolean", + "example": true + }, "user_id": { "type": "integer", "example": 3 @@ -1780,6 +3351,25 @@ var doc = `{ } } }, + "api.TagInfoResp": { + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "api.UserCountResp": { + "type": "object", + "properties": { + "user_count": { + "type": "integer" + } + } + }, "api.UserResp": { "type": "object", "properties": { @@ -1822,6 +3412,14 @@ var doc = `{ "api.UserSolveResp": { "type": "object", "properties": { + "correct": { + "type": "boolean", + "example": true + }, + "flag": { + "type": "string", + "example": "flag{example_flag}" + }, "id": { "type": "integer", "example": 5 @@ -1835,18 +3433,93 @@ var doc = `{ } } }, + "api.UsersResp": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "fristonio@gmail.com" + }, + "id": { + "type": "integer", + "example": 5 + }, + "rank": { + "type": "integer", + "example": 15 + }, + "role": { + "type": "string", + "example": "author" + }, + "score": { + "type": "integer", + "example": 750 + }, + "status": { + "type": "integer", + "example": 0 + }, + "username": { + "type": "string", + "example": "CTF is live now!" + } + } + }, "api.UsersStatisticsResp": { "type": "object", "properties": { "banned_users": { - "type": "integer" + "type": "integer", + "example": 60 }, "total_registered_users": { "type": "integer", "example": 120 }, "unbanned_users": { - "type": "integer" + "type": "integer", + "example": 60 + } + } + }, + "database.TimeSeries": { + "type": "object", + "properties": { + "score": { + "type": "integer", + "example": 750 + }, + "timestamp": { + "type": "string", + "example": "2018-12-31T22:20:08" + } + } + }, + "database.UserLeaderboardResp": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 5 + }, + "rank": { + "type": "integer", + "example": 15 + }, + "score": { + "type": "integer", + "example": 750 + }, + "timeSeriesData": { + "type": "array", + "items": { + "$ref": "#/definitions/database.TimeSeries" + } + }, + "username": { + "type": "string", + "example": "ABCD" } } } @@ -1860,49 +3533,20 @@ var doc = `{ } }` -type swaggerInfo struct { - Version string - Host string - BasePath string - Schemes []string - Title string - Description string -} - // SwaggerInfo holds exported Swagger Info so clients can modify it -var SwaggerInfo = swaggerInfo{ - Version: "1.0", - Host: "beast.sdslabs.co", - BasePath: "/", - Schemes: []string{}, - Title: "Beast API", - Description: "Beast the automatic deployment tool for backdoor", -} - -type s struct{} - -func (s *s) ReadDoc() string { - sInfo := SwaggerInfo - sInfo.Description = strings.Replace(sInfo.Description, "\n", "\\n", -1) - - t, err := template.New("swagger_info").Funcs(template.FuncMap{ - "marshal": func(v interface{}) string { - a, _ := json.Marshal(v) - return string(a) - }, - }).Parse(doc) - if err != nil { - return doc - } - - var tpl bytes.Buffer - if err := t.Execute(&tpl, sInfo); err != nil { - return doc - } - - return tpl.String() +var SwaggerInfo = &swag.Spec{ + Version: "0.2", + Host: "", + BasePath: "/", + Schemes: []string{"https"}, + Title: "Beast API", + Description: "Authenticated API for Beast CTF challenge deployment and competition services.", + InfoInstanceName: "swagger", + SwaggerTemplate: docTemplate, + LeftDelim: "{{", + RightDelim: "}}", } func init() { - swag.Register(swag.Name, &s{}) + swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo) } diff --git a/api/docs/swagger.json b/api/docs/swagger.json index 2294d64b..bcd196f1 100644 --- a/api/docs/swagger.json +++ b/api/docs/swagger.json @@ -1,25 +1,27 @@ { + "schemes": [ + "https" + ], "swagger": "2.0", "info": { - "description": "Beast the automatic deployment tool for backdoor", + "description": "Authenticated API for Beast CTF challenge deployment and competition services.", "title": "Beast API", "contact": { "name": "SDSLabs", "url": "https://chat.sdslabs.co", - "email": "contact.sdslabs.co.in" + "email": "contact@sdslabs.co.in" }, "license": { "name": "Apache 2.0", - "url": "http://www.apache.org/licenses/LICENSE-2.0.html" + "url": "https://www.apache.org/licenses/LICENSE-2.0.html" }, - "version": "1.0" + "version": "0.2" }, - "host": "beast.sdslabs.co", "basePath": "/", "paths": { - "/api/admin/statistics": { - "get": { - "description": "returns various information about the competition which are used to control competition", + "/api/admin/freezeLeaderboard": { + "post": { + "description": "freezes the user leaderboard on demand.", "consumes": [ "application/json" ], @@ -29,7 +31,7 @@ "tags": [ "info" ], - "summary": "returns competition info", + "summary": "Freeze user leaderboard", "parameters": [ { "type": "string", @@ -43,7 +45,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/api.CompetitionInfoResp" + "$ref": "#/definitions/api.UserResp" } }, "400": { @@ -51,164 +53,63 @@ "schema": { "$ref": "#/definitions/api.HTTPErrorResp" } - } - } - } - }, - "/api/admin/users/:action/:id": { - "post": { - "description": "Ban/unban a user based on his user id. This operation can only be done by admins", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "Ban/Unban a user based on his id and the action provided.", - "parameters": [ - { - "type": "string", - "description": "Action to perform ban/unban", - "name": "action", - "in": "query", - "required": true - }, - { - "type": "string", - "description": "Id of user", - "name": "id", - "in": "query", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/api.ChallengeStatusResp" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/api.HTTPPlainResp" - } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/api.HTTPPlainResp" + "$ref": "#/definitions/api.HTTPErrorResp" } } } } }, - "/api/config/competition-info": { - "post": { - "description": "Populates beast gobal config map by reparsing the config file $HOME/.beast/config.toml.", - "consumes": [ - "application/json" + "/api/admin/instances": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } ], "produces": [ "application/json" ], "tags": [ - "config" - ], - "summary": "Updates competition info in the beast global configuration, located at ~/.beast/config.toml.", - "parameters": [ - { - "type": "string", - "description": "Competition Name", - "name": "name", - "in": "formData", - "required": true - }, - { - "type": "string", - "description": "Some information about competition", - "name": "about", - "in": "formData", - "required": true - }, - { - "type": "string", - "description": "Competitions Prizes for the winners", - "name": "prizes", - "in": "formData" - }, - { - "type": "string", - "description": "Competition's starting time", - "name": "starting_time", - "in": "formData", - "required": true - }, - { - "type": "string", - "description": "Competition's ending time", - "name": "ending_time", - "in": "formData", - "required": true - }, - { - "type": "string", - "description": "Competition's timezone", - "name": "timezone", - "in": "formData", - "required": true - }, - { - "type": "file", - "description": "Competition's logo", - "name": "logo", - "in": "formData" - } + "admin" ], + "summary": "List all active instances", "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/api.HTTPPlainResp" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/api.HTTPPlainResp" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/api.HTTPErrorResp" + "type": "array", + "items": { + "$ref": "#/definitions/api.AdminInstanceResponse" + } } } } } }, - "/api/config/reload/": { - "patch": { - "description": "Populates beast gobal config map by reparsing the config file $HOME/.beast/config.toml.", - "consumes": [ - "application/json" + "/api/admin/instances/challenge/{challenge_name}": { + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } ], "produces": [ "application/json" ], "tags": [ - "config" + "admin" ], - "summary": "Reloads any changes in beast global configuration, located at ~/.beast/config.toml.", + "summary": "Delete all instances for a challenge", "parameters": [ { "type": "string", - "description": "Bearer", - "name": "Authorization", - "in": "header", + "description": "Challenge name", + "name": "challenge_name", + "in": "path", "required": true } ], @@ -218,42 +119,30 @@ "schema": { "$ref": "#/definitions/api.HTTPPlainResp" } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/api.HTTPPlainResp" - } } } } }, - "/api/info/challenge/info": { - "get": { - "description": "Returns all information about the challenges by the challenge name.", - "consumes": [ - "application/json" + "/api/admin/instances/user/{user_id}": { + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } ], "produces": [ "application/json" ], "tags": [ - "info" + "admin" ], - "summary": "Returns all information about the challenges.", + "summary": "Delete all instances owned by a user", "parameters": [ { "type": "string", - "description": "Bearer", - "name": "Authorization", - "in": "header", - "required": true - }, - { - "type": "string", - "description": "Name of challenge", - "name": "name", - "in": "query", + "description": "User ID", + "name": "user_id", + "in": "path", "required": true } ], @@ -261,61 +150,32 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/api.ChallengeInfoResp" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/api.HTTPErrorResp" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/api.HTTPErrorResp" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/api.HTTPErrorResp" + "$ref": "#/definitions/api.HTTPPlainResp" } } } } }, - "/api/info/challenges": { + "/api/admin/instances/{instance_id}": { "get": { - "description": "Returns information about all the challenges present in the database with and without filters.", - "consumes": [ - "application/json" + "security": [ + { + "ApiKeyAuth": [] + } ], "produces": [ "application/json" ], "tags": [ - "info" + "admin" ], - "summary": "Returns information about all challenges with and without filters.", + "summary": "Get an instance by ID", "parameters": [ { "type": "string", - "description": "Filter parameter by which challenges are filtered", - "name": "filter", - "in": "query" - }, - { - "type": "string", - "description": "Value of filtered parameter", - "name": "value", - "in": "query" - }, - { - "type": "string", - "description": "Bearer", - "name": "Authorization", - "in": "header", + "description": "Instance ID", + "name": "instance_id", + "in": "path", "required": true } ], @@ -323,43 +183,30 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/api.ChallengeInfoResp" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/api.HTTPErrorResp" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/api.HTTPErrorResp" + "$ref": "#/definitions/api.AdminInstanceResponse" } } } - } - }, - "/api/info/images/available": { - "get": { - "description": "Returns all the available base images which can be used for challenge creation as the base OS for challenge.", - "consumes": [ - "application/json" + }, + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } ], "produces": [ "application/json" ], "tags": [ - "info" + "admin" ], - "summary": "Gives all the base images that can be used while creating a beast challenge, this is a constant specified in beast global config", + "summary": "Delete an instance by ID", "parameters": [ { "type": "string", - "description": "Bearer", - "name": "Authorization", - "in": "header", + "description": "Instance ID", + "name": "instance_id", + "in": "path", "required": true } ], @@ -367,15 +214,15 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/api.AvailableImagesResp" + "$ref": "#/definitions/api.HTTPPlainResp" } } } } }, - "/api/info/logs": { + "/api/admin/leaderboard": { "get": { - "description": "Gives container logs for a particular challenge, useful for debugging purposes.", + "description": "Returns admin leaderboard of all users", "consumes": [ "application/json" ], @@ -385,7 +232,7 @@ "tags": [ "info" ], - "summary": "Handles route related to logs handling of container", + "summary": "Returns admin leaderboard", "parameters": [ { "type": "string", @@ -396,8 +243,8 @@ }, { "type": "string", - "description": "The name of the challenge to get the logs for.", - "name": "challenge", + "description": "Page number", + "name": "page", "in": "query" } ], @@ -405,27 +252,27 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/api.LogsInfoResp" + "$ref": "#/definitions/api.UserResp" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/api.HTTPPlainResp" + "$ref": "#/definitions/api.HTTPErrorResp" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/api.HTTPPlainResp" + "$ref": "#/definitions/api.HTTPErrorResp" } } } } }, - "/api/info/ports/used": { + "/api/admin/statistics": { "get": { - "description": "Returns the ports in use by beast, which cannot be used in creating a new challenge..", + "description": "returns statistics of users in competition (currently limited to ban/unban status of users)", "consumes": [ "application/json" ], @@ -435,7 +282,7 @@ "tags": [ "info" ], - "summary": "Returns ports in use by beast by looking in the hack git repository, also returns min and max value of port allowed while specifying in beast challenge config.", + "summary": "statistics of users in competition", "parameters": [ { "type": "string", @@ -449,13 +296,25 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/api.PortsInUseResp" + "$ref": "#/definitions/api.UsersStatisticsResp" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.HTTPErrorResp" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.HTTPErrorResp" } } } } }, - "/api/info/submissions": { + "/api/admin/submissions": { "get": { "description": "Handles submissions made by the user", "consumes": [ @@ -493,9 +352,9 @@ } } }, - "/api/info/user": { - "get": { - "description": "Returns user info based on userId", + "/api/admin/unfreezeLeaderboard": { + "post": { + "description": "unfreezes the user leaderboard on demand.", "consumes": [ "application/json" ], @@ -505,7 +364,7 @@ "tags": [ "info" ], - "summary": "Returns user info", + "summary": "Unfreeze user leaderboard", "parameters": [ { "type": "string", @@ -513,18 +372,6 @@ "name": "Authorization", "in": "header", "required": true - }, - { - "type": "string", - "description": "User's id", - "name": "value", - "in": "formData" - }, - { - "type": "string", - "description": "username", - "name": "value", - "in": "query" } ], "responses": { @@ -549,9 +396,9 @@ } } }, - "/api/info/user/available": { - "get": { - "description": "Returns all available user's info", + "/api/admin/users/{action}/{id}": { + "post": { + "description": "Ban/Unban/Hide/Unhide a user based on his user id. This operation can only be done by admins", "consumes": [ "application/json" ], @@ -559,15 +406,1081 @@ "application/json" ], "tags": [ - "info" + "admin" + ], + "summary": "Ban/Unban/Hide/Unhide a user based on his id and the action provided.", + "parameters": [ + { + "type": "string", + "description": "Action to perform Ban/Unban/Hide/Unhide", + "name": "action", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "Id of user", + "name": "id", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.ChallengeStatusResp" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.HTTPPlainResp" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.HTTPPlainResp" + } + } + } + } + }, + "/api/config/challenge-info": { + "post": { + "description": "Updates challenge info in the database, located at ~/.beast/beast.db.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "config" + ], + "summary": "Updates challenge info in the database, located at ~/.beast/beast.db.", + "parameters": [ + { + "type": "string", + "description": "Challenge Name", + "name": "name", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "Challenge's description", + "name": "desc", + "in": "formData" + }, + { + "type": "string", + "description": "Challenge's points", + "name": "points", + "in": "formData" + }, + { + "type": "string", + "description": "Challenge's flag", + "name": "flag", + "in": "formData" + }, + { + "type": "string", + "description": "Challenge's tags", + "name": "tags", + "in": "formData" + }, + { + "type": "string", + "description": "Challenge's ports", + "name": "ports", + "in": "formData" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.HTTPPlainResp" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.HTTPPlainResp" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.HTTPErrorResp" + } + } + } + } + }, + "/api/info/challenge/{name}": { + "get": { + "description": "Returns all information about the challenges by the challenge name.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "info" + ], + "summary": "Returns all information about the challenges.", + "parameters": [ + { + "type": "string", + "description": "Bearer", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Name of challenge", + "name": "name", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.Challenge" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.HTTPErrorResp" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.HTTPErrorResp" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.HTTPErrorResp" + } + } + } + } + }, + "/api/info/challenges": { + "get": { + "description": "Returns information about all the challenges present in the database with and without filters.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "info" + ], + "summary": "Returns metadata about all challenges with and without filters.", + "parameters": [ + { + "type": "string", + "description": "Filter parameter by which challenges are filtered", + "name": "filter", + "in": "query" + }, + { + "type": "string", + "description": "Value of filtered parameter", + "name": "value", + "in": "query" + }, + { + "type": "string", + "description": "Bearer", + "name": "Authorization", + "in": "header", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/api.ChallengeMetadata" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.HTTPErrorResp" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.HTTPErrorResp" + } + } + } + } + }, + "/api/info/competition-info": { + "get": { + "description": "returns various information about the competition which are used to control competition", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "info" + ], + "summary": "returns competition info", + "parameters": [ + { + "type": "string", + "description": "Bearer", + "name": "Authorization", + "in": "header", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.CompetitionInfoResp" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.HTTPErrorResp" + } + } + } + } + }, + "/api/info/download": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "info" + ], + "parameters": [ + { + "type": "string", + "description": "Bearer", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "The name of the challenge to get the logs for.", + "name": "challenge", + "in": "query" + }, + { + "type": "string", + "description": "The name of the static asset requested.", + "name": "asset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.LogsInfoResp" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.HTTPPlainResp" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.HTTPPlainResp" + } + } + } + } + }, + "/api/info/hint/{hintID}": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "info" + ], + "summary": "Read or purchase a challenge hint", + "parameters": [ + { + "type": "integer", + "description": "Hint ID", + "name": "hintID", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.HintResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.HTTPErrorResp" + } + } + } + }, + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "info" + ], + "summary": "Read or purchase a challenge hint", + "parameters": [ + { + "type": "integer", + "description": "Hint ID", + "name": "hintID", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.HintResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.HTTPErrorResp" + } + } + } + } + }, + "/api/info/images/available": { + "get": { + "description": "Returns all the available base images which can be used for challenge creation as the base OS for challenge.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "info" + ], + "summary": "Gives all the base images that can be used while creating a beast challenge, this is a constant specified in beast global config", + "parameters": [ + { + "type": "string", + "description": "Bearer", + "name": "Authorization", + "in": "header", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.AvailableImagesResp" + } + } + } + } + }, + "/api/info/leaderboard": { + "get": { + "description": "Returns leaderboard of all users", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "info" + ], + "summary": "Returns leaderboard", + "parameters": [ + { + "type": "string", + "description": "Bearer", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Page number", + "name": "page", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.UserResp" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.HTTPErrorResp" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.HTTPErrorResp" + } + } + } + } + }, + "/api/info/leaderboard-graph": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "info" + ], + "summary": "Return leaderboard score history", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/database.UserLeaderboardResp" + } + } + } + } + } + }, + "/api/info/submissions/challenge/{challenge_id}": { + "get": { + "description": "Returns all user attempts for a given challenge.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "info" + ], + "summary": "Get challenge attempts", + "parameters": [ + { + "type": "integer", + "description": "Challenge ID", + "name": "challenge_id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Bearer", + "name": "Authorization", + "in": "header", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/api.UserSolveResp" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.HTTPErrorResp" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.HTTPErrorResp" + } + } + } + } + }, + "/api/info/submissions/user/{user_id}": { + "get": { + "description": "Returns all submissions for a specific user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "info" + ], + "summary": "Get submissions by user", + "parameters": [ + { + "type": "integer", + "description": "User ID", + "name": "user_id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Bearer", + "name": "Authorization", + "in": "header", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/api.SubmissionResp" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.HTTPErrorResp" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.HTTPErrorResp" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.HTTPErrorResp" + } + } + } + } + }, + "/api/info/tags": { + "get": { + "description": "returns all unique tags", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "info" + ], + "summary": "returns all tags", + "parameters": [ + { + "type": "string", + "description": "Bearer", + "name": "Authorization", + "in": "header", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.TagInfoResp" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.HTTPErrorResp" + } + } + } + } + }, + "/api/info/user/{username}": { + "get": { + "description": "Returns user info based on userId", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "info" + ], + "summary": "Returns user info", + "parameters": [ + { + "type": "string", + "description": "Bearer", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "type": "integer", + "description": "User ID", + "name": "user_id", + "in": "query" + }, + { + "type": "string", + "description": "Username", + "name": "username", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.UserResp" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.HTTPErrorResp" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.HTTPErrorResp" + } + } + } + } + }, + "/api/info/usercount": { + "get": { + "description": "Returns the number of users in the database with role=contestant", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "info" + ], + "summary": "Returns the number of users in the database with role=contestant", + "parameters": [ + { + "type": "string", + "description": "Bearer", + "name": "Authorization", + "in": "header", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.UserCountResp" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.HTTPErrorResp" + } + } + } + } + }, + "/api/info/users": { + "get": { + "description": "Returns all available user's info", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "info" + ], + "summary": "Returns all user's info", + "parameters": [ + { + "type": "string", + "description": "Bearer", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Sort by username or score", + "name": "sort", + "in": "query" + }, + { + "type": "string", + "description": "Score order: asc or desc", + "name": "order", + "in": "query" + }, + { + "type": "string", + "description": "Filter by banned, active, or hidden", + "name": "filter", + "in": "query" + }, + { + "type": "string", + "description": "Response format: json or csv", + "name": "format", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/api.UsersResp" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.HTTPErrorResp" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.HTTPErrorResp" + } + } + } + } + }, + "/api/instances": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "instances" + ], + "summary": "List the current user's instances", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/api.InstanceResponse" + } + } + } + } + } + }, + "/api/instances/{challenge_name}": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "instances" + ], + "summary": "Get the current user's challenge instance", + "parameters": [ + { + "type": "string", + "description": "Challenge name", + "name": "challenge_name", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.InstanceResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.HTTPErrorResp" + } + } + } + }, + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "instances" + ], + "summary": "Delete the current user's challenge instance", + "parameters": [ + { + "type": "string", + "description": "Challenge name", + "name": "challenge_name", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.HTTPPlainResp" + } + } + } + } + }, + "/api/instances/{challenge_name}/extend": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "instances" + ], + "summary": "Extend the current user's challenge instance", + "parameters": [ + { + "type": "string", + "description": "Challenge name", + "name": "challenge_name", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Requested extension in seconds", + "name": "seconds", + "in": "formData" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.InstanceResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.HTTPErrorResp" + } + } + } + } + }, + "/api/instances/{challenge_name}/spawn": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "instances" + ], + "summary": "Spawn a per-user challenge instance", + "parameters": [ + { + "type": "string", + "description": "Challenge name", + "name": "challenge_name", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.InstanceResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.HTTPErrorResp" + } + } + } + } + }, + "/api/manage/challenge/": { + "post": { + "description": "Handles challenge management routes with actions which includes - DEPLOY, UNDEPLOY, PURGE.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "manage" + ], + "summary": "Handles challenge management actions.", + "parameters": [ + { + "type": "string", + "description": "Name of the challenge to be managed, here name is the unique identifier for challenge", + "name": "name", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "Action for the challenge", + "name": "action", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.HTTPPlainResp" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.HTTPPlainResp" + } + } + } + } + }, + "/api/manage/challenge/multiple/": { + "post": { + "description": "Handles challenge management routes with actions which includes - DEPLOY, UNDEPLOY, PURGE of multiple challenges.", + "consumes": [ + "application/json" ], - "summary": "Returns all user's info", + "produces": [ + "application/json" + ], + "summary": "Handles multiple challenge management actions.", "parameters": [ { "type": "string", - "description": "Bearer", - "name": "Authorization", - "in": "header", + "description": "Name of the challenge to be managed, here name is the unique identifier for challenges seperated by a comma", + "name": "name", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "Action for the challenge", + "name": "action", + "in": "query", "required": true } ], @@ -575,11 +1488,49 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/api.UserResp" + "$ref": "#/definitions/api.HTTPPlainResp" } }, - "404": { - "description": "Not Found", + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.HTTPPlainResp" + } + } + } + } + }, + "/api/manage/challenge/upload": { + "post": { + "description": "Handles the challenge management from a challenge in zip file. Currently prepare the zip file", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "manage" + ], + "summary": "Unzip and fetch info from beast.toml file in challenge", + "parameters": [ + { + "type": "file", + "description": ".zip file to be uploaded to fetch challenge info", + "name": "file", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.ChallengePreviewResp" + } + }, + "400": { + "description": "Bad Request", "schema": { "$ref": "#/definitions/api.HTTPErrorResp" } @@ -593,11 +1544,12 @@ } } }, - "/api/manage/challenge/": { + "/api/manage/challenge/validateflag": { "post": { - "description": "Handles challenge management routes with actions which includes - DEPLOY, UNDEPLOY, PURGE.", - "consumes": [ - "application/json" + "security": [ + { + "ApiKeyAuth": [] + } ], "produces": [ "application/json" @@ -605,19 +1557,57 @@ "tags": [ "manage" ], - "summary": "Handles challenge management actions.", + "summary": "Validate a configured challenge flag as its manager", "parameters": [ { "type": "string", - "description": "Name of the challenge to be managed, here name is the unique identifier for challenge", - "name": "name", - "in": "query", + "description": "Challenge name", + "name": "challenge_name", + "in": "formData", "required": true }, { "type": "string", - "description": "Action for the challenge", - "name": "action", + "description": "Flag to validate", + "name": "flag", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.HTTPPlainResp" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/api.HTTPErrorResp" + } + } + } + } + }, + "/api/manage/challenge/verify": { + "post": { + "description": "Commits the challenge container for later use", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "manage" + ], + "summary": "Commits the challenge container so that later the challenge image can be used deployment", + "parameters": [ + { + "type": "string", + "description": "Name of the challenge to commit", + "name": "challenge", "in": "query", "required": true } @@ -629,8 +1619,8 @@ "$ref": "#/definitions/api.HTTPPlainResp" } }, - "400": { - "description": "Bad Request", + "500": { + "description": "Internal Server Error", "schema": { "$ref": "#/definitions/api.HTTPPlainResp" } @@ -638,9 +1628,13 @@ } } }, - "/api/manage/challenge/upload": { + "/api/manage/challenge/{name}/exec": { "post": { - "description": "Handles the challenge management from a challenge in tar file. Currently prepare the tar file", + "security": [ + { + "ApiKeyAuth": [] + } + ], "consumes": [ "application/json" ], @@ -650,21 +1644,30 @@ "tags": [ "manage" ], - "summary": "Untar and fetch info from beast.toml file in challenge", + "summary": "Execute an argument-vector command in an owned challenge container", "parameters": [ { - "type": "file", - "description": ".tar file to be uploaded to fetch challenge info", - "name": "file", - "in": "formData", + "type": "string", + "description": "Challenge name", + "name": "name", + "in": "path", "required": true + }, + { + "description": "Bounded exec request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/api.ExecChallengeRequest" + } } ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/api.ChallengePreviewResp" + "$ref": "#/definitions/api.ExecChallengeResponse" } }, "400": { @@ -673,8 +1676,8 @@ "$ref": "#/definitions/api.HTTPErrorResp" } }, - "500": { - "description": "Internal Server Error", + "403": { + "description": "Forbidden", "schema": { "$ref": "#/definitions/api.HTTPErrorResp" } @@ -714,7 +1717,7 @@ } } }, - "/api/manage/deploy/local": { + "/api/manage/deploy/local/": { "post": { "description": "Handles deployment of a challenge using the absolute directory path", "consumes": [ @@ -758,7 +1761,62 @@ } } }, - "/api/manage/multiple/:action": { + "/api/manage/logs": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Gives container logs for a particular challenge, useful for debugging purposes.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "info" + ], + "summary": "Handles route related to logs handling of container", + "parameters": [ + { + "type": "string", + "description": "Bearer", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "The name of the challenge to get the logs for.", + "name": "challenge", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.LogsInfoResp" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.HTTPPlainResp" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.HTTPPlainResp" + } + } + } + } + }, + "/api/manage/multiple/{action}": { "post": { "description": "Handles challenge management routes for multiple the challenges with actions which includes - DEPLOY, UNDEPLOY.", "consumes": [ @@ -802,7 +1860,7 @@ } } }, - "/api/manage/schedule/:action": { + "/api/manage/schedule/{action}": { "post": { "description": "Handles scheduleing of challenge action to executed at some later point of time", "consumes": [ @@ -871,7 +1929,7 @@ } } }, - "/api/manage/static/:action": { + "/api/manage/static/{action}": { "post": { "description": "Handles beast static content serving container routes.", "consumes": [ @@ -961,7 +2019,7 @@ } }, "/api/notification/available": { - "post": { + "get": { "description": "Fetch all the notifications from database", "consumes": [ "application/json" @@ -990,7 +2048,7 @@ } }, "/api/notification/delete": { - "post": { + "delete": { "description": "Removes notifications", "consumes": [ "application/json" @@ -1033,8 +2091,32 @@ } } }, + "/api/notification/stream": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ + "text/event-stream" + ], + "tags": [ + "notification" + ], + "summary": "Stream server-sent notifications", + "responses": { + "200": { + "description": "SSE stream", + "schema": { + "type": "string" + } + } + } + } + }, "/api/notification/update": { - "post": { + "put": { "description": "Updates any changes in the notifications", "consumes": [ "application/json" @@ -1091,7 +2173,7 @@ } } }, - "/api/remote/reset/": { + "/api/remote/reset": { "post": { "description": "Resets local copy of remote git directory, it first deletes the existing directory and then clone from the remote again.", "consumes": [ @@ -1129,7 +2211,7 @@ } } }, - "/api/remote/sync/": { + "/api/remote/sync": { "post": { "description": "Syncs beasts local challenges database with the remote git repository(hack) the local copy of the challenge database is located at $HOME/.beast/remote/$REMOTE_NAME.", "consumes": [ @@ -1167,7 +2249,61 @@ } } }, - "/api/status/all/:filter": { + "/api/status/all": { + "get": { + "description": "This returns the challenges in the status provided, along with their name and last updated time.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "status" + ], + "summary": "Returns challenge deployment status from the beast database for the challenges which matches the stauts according to filter.", + "parameters": [ + { + "type": "string", + "description": "Status type to filter with, if none specified then all", + "name": "filter", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "Bearer", + "name": "Authorization", + "in": "header", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/api.ChallengeStatusResp" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.HTTPPlainResp" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.HTTPPlainResp" + } + } + } + } + }, + "/api/status/all/{filter}": { "get": { "description": "This returns the challenges in the status provided, along with their name and last updated time.", "consumes": [ @@ -1221,7 +2357,7 @@ } } }, - "/api/status/challenge/:name": { + "/api/status/challenge/{name}": { "get": { "description": "Returns challenge deployment status from the beast database, for those challenges which are not present a status value NA is returned.", "consumes": [ @@ -1423,12 +2559,6 @@ "name": "email", "in": "formData", "required": true - }, - { - "type": "string", - "description": "User's ssh-key", - "name": "ssh-key", - "in": "formData" } ], "responses": { @@ -1493,9 +2623,209 @@ } } } + }, + "/auth/send-otp": { + "post": { + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Send an email verification OTP", + "parameters": [ + { + "type": "string", + "description": "Email address", + "name": "email", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.HTTPPlainResp" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.HTTPErrorResp" + } + } + } + } + }, + "/auth/send-otp-forget": { + "post": { + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Send a password-reset OTP", + "parameters": [ + { + "type": "string", + "description": "Registered email address", + "name": "email", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.HTTPPlainResp" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.HTTPErrorResp" + } + } + } + } + }, + "/auth/verify-otp": { + "post": { + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Verify an email OTP", + "parameters": [ + { + "type": "string", + "description": "Email address", + "name": "email", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "One-time code", + "name": "otp", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.HTTPPlainResp" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.HTTPErrorResp" + } + } + } + } + }, + "/auth/verify-otp-forget": { + "post": { + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Verify a password-reset OTP", + "parameters": [ + { + "type": "string", + "description": "Registered email address", + "name": "email", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "One-time code", + "name": "otp", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.HTTPAuthorizeResp" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.HTTPErrorResp" + } + } + } + } } }, "definitions": { + "api.AdminInstanceResponse": { + "type": "object", + "properties": { + "challenge_name": { + "type": "string" + }, + "container_id": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "deployment_type": { + "type": "string" + }, + "expires_at": { + "type": "string" + }, + "hosted_address": { + "type": "string" + }, + "instance_id": { + "type": "string" + }, + "port": { + "type": "integer" + }, + "ttl_seconds": { + "type": "integer" + }, + "user_id": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, "api.AvailableImagesResp": { "type": "object", "properties": { @@ -1505,8 +2835,8 @@ "type": "string" }, "example": [ - "['ubuntu16.04'", - " 'ubuntu18.04']" + "ubuntu:24.04", + "debian:bookworm" ] }, "message": { @@ -1515,9 +2845,29 @@ } } }, - "api.ChallengeInfoResp": { + "api.Challenge": { "type": "object", "properties": { + "additionalLinks": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "https://link1.example", + "https://link2.example" + ] + }, + "assets": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "image1.png", + "zippy.zip" + ] + }, "category": { "type": "string", "example": "web" @@ -1525,14 +2875,111 @@ "createdAt": { "type": "string" }, + "deployedLink": { + "type": "string", + "example": "beast.sdslabs.co or ip:port" + }, + "deployedStatus": { + "type": "string", + "example": "deployed" + }, "description": { - "type": "string" + "type": "string", + "example": "A simple web challenge" + }, + "difficulty": { + "type": "string", + "example": "easy" }, "hints": { + "type": "array", + "items": { + "$ref": "#/definitions/api.HintInfo" + } + }, + "id": { + "type": "integer", + "example": 0 + }, + "instanceExpiration": { + "type": "integer", + "example": 300 + }, + "instanced": { + "type": "boolean", + "example": false + }, + "maxAttemptLimit": { + "type": "integer", + "example": 5 + }, + "name": { + "type": "string", + "example": "Web Challenge" + }, + "points": { + "type": "integer", + "example": 50 + }, + "preRequisite": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "chall1", + "chall2" + ] + }, + "previousTries": { + "type": "integer", + "example": 3 + }, + "solveStatus": { + "type": "boolean", + "example": true + }, + "solvesNumber": { + "type": "integer", + "example": 100 + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "pwn", + "misc" + ] + } + } + }, + "api.ChallengeMetadata": { + "type": "object", + "properties": { + "createdAt": { "type": "string" }, + "deployedStatus": { + "type": "string", + "example": "deployed" + }, + "difficulty": { + "type": "string", + "example": "easy" + }, "id": { - "type": "integer" + "type": "integer", + "example": 0 + }, + "instanceExpiration": { + "type": "integer", + "example": 300 + }, + "instanced": { + "type": "boolean", + "example": false }, "name": { "type": "string", @@ -1542,43 +2989,74 @@ "type": "integer", "example": 50 }, - "ports": { + "preRequisite": { "type": "array", "items": { - "type": "integer" - } + "type": "string" + }, + "example": [ + "chall1", + "chall2" + ] }, - "solves": { - "type": "array", - "items": { - "$ref": "#/definitions/api.UserSolveResp" - } + "solveStatus": { + "type": "boolean", + "example": true }, "solvesNumber": { "type": "integer", "example": 100 }, - "status": { - "type": "string", - "example": "deployed" + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "pwn", + "misc" + ] } } }, "api.ChallengePreviewResp": { "type": "object", "properties": { + "additionalLinks": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "https://link1.example", + "https://link2.example" + ] + }, + "assets": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "image1.png", + "zippy.zip" + ] + }, "category": { "type": "string", "example": "web" }, + "deployedLink": { + "type": "string", + "example": "beast.sdslabs.co" + }, "description": { - "type": "string" + "type": "string", + "example": "A simple web challenge" }, - "hints": { - "type": "array", - "items": { - "type": "string" - } + "maxAttemptLimit": { + "type": "integer", + "example": 5 }, "name": { "type": "string", @@ -1592,7 +3070,31 @@ "type": "array", "items": { "type": "integer" - } + }, + "example": [ + 3001, + 3002 + ] + }, + "preRequisite": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "web-php", + "simple" + ] + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "pwn", + "misc" + ] } } }, @@ -1601,7 +3103,7 @@ "properties": { "category": { "type": "string", - "example": "web" + "example": "bare" }, "id": { "type": "integer", @@ -1617,6 +3119,16 @@ }, "solvedAt": { "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "pwn", + "misc" + ] } } }, @@ -1662,7 +3174,42 @@ "type": "string" }, "timezone": { + "type": "string", + "example": "Asia/Calcutta: UTC +05:30" + } + } + }, + "api.ExecChallengeRequest": { + "type": "object", + "properties": { + "command": { + "type": "array", + "items": { + "type": "string" + } + }, + "instance_id": { + "type": "string" + }, + "timeout_seconds": { + "type": "integer" + } + } + }, + "api.ExecChallengeResponse": { + "type": "object", + "properties": { + "exit_code": { + "type": "integer" + }, + "stderr": { + "type": "string" + }, + "stdout": { "type": "string" + }, + "truncated": { + "type": "boolean" } } }, @@ -1701,60 +3248,95 @@ } } }, - "api.LogsInfoResp": { + "api.HintInfo": { "type": "object", "properties": { - "stderr": { - "type": "string", - "example": "[ERROR] Challenge deployment failed." + "id": { + "type": "integer" }, - "stdout": { - "type": "string", - "example": "[INFO] Challenge is starting to deploy" + "points": { + "type": "integer" } } }, - "api.PortsInUseResp": { + "api.HintResponse": { "type": "object", "properties": { - "port_max_value": { - "type": "integer", - "example": 20000 + "description": { + "type": "string", + "example": "This is a hint" }, - "port_min_value": { + "points": { "type": "integer", - "example": 10000 + "example": 10 + } + } + }, + "api.InstanceResponse": { + "type": "object", + "properties": { + "challenge_name": { + "type": "string" }, - "ports_in_use": { - "type": "array", - "items": { - "type": "integer" - } + "created_at": { + "type": "string" + }, + "expires_at": { + "type": "string" + }, + "hosted_address": { + "type": "string" + }, + "instance_id": { + "type": "string" + }, + "port": { + "type": "integer" + }, + "ttl_seconds": { + "type": "integer" } } }, - "api.SubmissionResp": { + "api.LogsInfoResp": { "type": "object", "properties": { - "category": { + "stderr": { "type": "string", - "example": "web" + "example": "[ERROR] Challenge deployment failed." }, + "stdout": { + "type": "string", + "example": "[INFO] Challenge is starting to deploy" + } + } + }, + "api.SubmissionResp": { + "type": "object", + "properties": { "chall_id": { "type": "integer", "example": 3 }, + "cheating": { + "type": "boolean", + "example": false + }, + "flag": { + "type": "string", + "example": "flag{@#$}" + }, "name": { "type": "string", "example": "Web Challenge" }, - "points": { - "type": "integer", - "example": 50 - }, "solvedAt": { "type": "string" }, + "success": { + "type": "boolean", + "example": true + }, "user_id": { "type": "integer", "example": 3 @@ -1762,10 +3344,25 @@ "username": { "type": "string", "example": "fristonio" - }, - "flag": { - "type": "string", - "example": "flag{@#$}" + } + } + }, + "api.TagInfoResp": { + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "api.UserCountResp": { + "type": "object", + "properties": { + "user_count": { + "type": "integer" } } }, @@ -1811,6 +3408,14 @@ "api.UserSolveResp": { "type": "object", "properties": { + "correct": { + "type": "boolean", + "example": true + }, + "flag": { + "type": "string", + "example": "flag{example_flag}" + }, "id": { "type": "integer", "example": 5 @@ -1824,18 +3429,93 @@ } } }, + "api.UsersResp": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "fristonio@gmail.com" + }, + "id": { + "type": "integer", + "example": 5 + }, + "rank": { + "type": "integer", + "example": 15 + }, + "role": { + "type": "string", + "example": "author" + }, + "score": { + "type": "integer", + "example": 750 + }, + "status": { + "type": "integer", + "example": 0 + }, + "username": { + "type": "string", + "example": "CTF is live now!" + } + } + }, "api.UsersStatisticsResp": { "type": "object", "properties": { "banned_users": { - "type": "integer" + "type": "integer", + "example": 60 }, "total_registered_users": { "type": "integer", "example": 120 }, "unbanned_users": { - "type": "integer" + "type": "integer", + "example": 60 + } + } + }, + "database.TimeSeries": { + "type": "object", + "properties": { + "score": { + "type": "integer", + "example": 750 + }, + "timestamp": { + "type": "string", + "example": "2018-12-31T22:20:08" + } + } + }, + "database.UserLeaderboardResp": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 5 + }, + "rank": { + "type": "integer", + "example": 15 + }, + "score": { + "type": "integer", + "example": 750 + }, + "timeSeriesData": { + "type": "array", + "items": { + "$ref": "#/definitions/database.TimeSeries" + } + }, + "username": { + "type": "string", + "example": "ABCD" } } } diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml index f81d0b2e..d4c3626a 100644 --- a/api/docs/swagger.yaml +++ b/api/docs/swagger.yaml @@ -1,11 +1,36 @@ basePath: / definitions: + api.AdminInstanceResponse: + properties: + challenge_name: + type: string + container_id: + type: string + created_at: + type: string + deployment_type: + type: string + expires_at: + type: string + hosted_address: + type: string + instance_id: + type: string + port: + type: integer + ttl_seconds: + type: integer + user_id: + type: string + username: + type: string + type: object api.AvailableImagesResp: properties: images: example: - - '[''ubuntu16.04''' - - ' ''ubuntu18.04'']' + - ubuntu:24.04 + - debian:bookworm items: type: string type: array @@ -13,18 +38,54 @@ definitions: example: Available Base images. type: string type: object - api.ChallengeInfoResp: + api.Challenge: properties: + additionalLinks: + example: + - https://link1.example + - https://link2.example + items: + type: string + type: array + assets: + example: + - image1.png + - zippy.zip + items: + type: string + type: array category: example: web type: string createdAt: type: string + deployedLink: + example: beast.sdslabs.co or ip:port + type: string + deployedStatus: + example: deployed + type: string description: + example: A simple web challenge type: string - hints: + difficulty: + example: easy type: string + hints: + items: + $ref: '#/definitions/api.HintInfo' + type: array id: + example: 0 + type: integer + instanceExpiration: + example: 300 + type: integer + instanced: + example: false + type: boolean + maxAttemptLimit: + example: 5 type: integer name: example: Web Challenge @@ -32,32 +93,104 @@ definitions: points: example: 50 type: integer - ports: - items: - type: integer - type: array - solves: + preRequisite: + example: + - chall1 + - chall2 items: - $ref: '#/definitions/api.UserSolveResp' + type: string type: array + previousTries: + example: 3 + type: integer + solveStatus: + example: true + type: boolean solvesNumber: example: 100 type: integer - status: + tags: + example: + - pwn + - misc + items: + type: string + type: array + type: object + api.ChallengeMetadata: + properties: + createdAt: + type: string + deployedStatus: example: deployed type: string + difficulty: + example: easy + type: string + id: + example: 0 + type: integer + instanceExpiration: + example: 300 + type: integer + instanced: + example: false + type: boolean + name: + example: Web Challenge + type: string + points: + example: 50 + type: integer + preRequisite: + example: + - chall1 + - chall2 + items: + type: string + type: array + solveStatus: + example: true + type: boolean + solvesNumber: + example: 100 + type: integer + tags: + example: + - pwn + - misc + items: + type: string + type: array type: object api.ChallengePreviewResp: properties: + additionalLinks: + example: + - https://link1.example + - https://link2.example + items: + type: string + type: array + assets: + example: + - image1.png + - zippy.zip + items: + type: string + type: array category: example: web type: string + deployedLink: + example: beast.sdslabs.co + type: string description: + example: A simple web challenge type: string - hints: - items: - type: string - type: array + maxAttemptLimit: + example: 5 + type: integer name: example: Web Challenge type: string @@ -65,14 +198,31 @@ definitions: example: 50 type: integer ports: + example: + - 3001 + - 3002 items: type: integer type: array + preRequisite: + example: + - web-php + - simple + items: + type: string + type: array + tags: + example: + - pwn + - misc + items: + type: string + type: array type: object api.ChallengeSolveResp: properties: category: - example: web + example: bare type: string id: example: 4 @@ -85,6 +235,13 @@ definitions: type: integer solvedAt: type: string + tags: + example: + - pwn + - misc + items: + type: string + type: array type: object api.ChallengeStatusResp: properties: @@ -116,7 +273,30 @@ definitions: starting_time: type: string timezone: + example: 'Asia/Calcutta: UTC +05:30' + type: string + type: object + api.ExecChallengeRequest: + properties: + command: + items: + type: string + type: array + instance_id: type: string + timeout_seconds: + type: integer + type: object + api.ExecChallengeResponse: + properties: + exit_code: + type: integer + stderr: + type: string + stdout: + type: string + truncated: + type: boolean type: object api.HTTPAuthorizeResp: properties: @@ -142,6 +322,39 @@ definitions: example: Messsage in response to your request type: string type: object + api.HintInfo: + properties: + id: + type: integer + points: + type: integer + type: object + api.HintResponse: + properties: + description: + example: This is a hint + type: string + points: + example: 10 + type: integer + type: object + api.InstanceResponse: + properties: + challenge_name: + type: string + created_at: + type: string + expires_at: + type: string + hosted_address: + type: string + instance_id: + type: string + port: + type: integer + ttl_seconds: + type: integer + type: object api.LogsInfoResp: properties: stderr: @@ -151,44 +364,43 @@ definitions: example: '[INFO] Challenge is starting to deploy' type: string type: object - api.PortsInUseResp: - properties: - port_max_value: - example: 20000 - type: integer - port_min_value: - example: 10000 - type: integer - ports_in_use: - items: - type: integer - type: array - type: object api.SubmissionResp: properties: - category: - example: web - type: string chall_id: example: 3 type: integer + cheating: + example: false + type: boolean + flag: + example: flag{@#$} + type: string name: example: Web Challenge type: string - points: - example: 50 - type: integer solvedAt: type: string + success: + example: true + type: boolean user_id: example: 3 type: integer username: example: fristonio type: string - flag: - example: flag{@#$} - type: string + type: object + api.TagInfoResp: + properties: + tags: + items: + type: string + type: array + type: object + api.UserCountResp: + properties: + user_count: + type: integer type: object api.UserResp: properties: @@ -220,6 +432,12 @@ definitions: type: object api.UserSolveResp: properties: + correct: + example: true + type: boolean + flag: + example: flag{example_flag} + type: string id: example: 5 type: integer @@ -229,35 +447,88 @@ definitions: example: fristonio type: string type: object + api.UsersResp: + properties: + email: + example: fristonio@gmail.com + type: string + id: + example: 5 + type: integer + rank: + example: 15 + type: integer + role: + example: author + type: string + score: + example: 750 + type: integer + status: + example: 0 + type: integer + username: + example: CTF is live now! + type: string + type: object api.UsersStatisticsResp: properties: banned_users: + example: 60 type: integer total_registered_users: example: 120 type: integer unbanned_users: + example: 60 + type: integer + type: object + database.TimeSeries: + properties: + score: + example: 750 + type: integer + timestamp: + example: 2018-12-31T22:20:08 + type: string + type: object + database.UserLeaderboardResp: + properties: + id: + example: 5 + type: integer + rank: + example: 15 type: integer + score: + example: 750 + type: integer + timeSeriesData: + items: + $ref: '#/definitions/database.TimeSeries' + type: array + username: + example: ABCD + type: string type: object -host: beast.sdslabs.co info: contact: - email: contact.sdslabs.co.in + email: contact@sdslabs.co.in name: SDSLabs url: https://chat.sdslabs.co - description: Beast the automatic deployment tool for backdoor + description: Authenticated API for Beast CTF challenge deployment and competition + services. license: name: Apache 2.0 - url: http://www.apache.org/licenses/LICENSE-2.0.html + url: https://www.apache.org/licenses/LICENSE-2.0.html title: Beast API - version: "1.0" + version: "0.2" paths: - /api/admin/statistics: - get: + /api/admin/freezeLeaderboard: + post: consumes: - application/json - description: returns various information about the competition which are used - to control competition + description: freezes the user leaderboard on demand. parameters: - description: Bearer in: header @@ -270,29 +541,40 @@ paths: "200": description: OK schema: - $ref: '#/definitions/api.CompetitionInfoResp' + $ref: '#/definitions/api.UserResp' "400": description: Bad Request schema: $ref: '#/definitions/api.HTTPErrorResp' - summary: returns competition info + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.HTTPErrorResp' + summary: Freeze user leaderboard tags: - info - /api/admin/users/:action/:id: - post: - consumes: + /api/admin/instances: + get: + produces: - application/json - description: Ban/unban a user based on his user id. This operation can only - be done by admins + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/api.AdminInstanceResponse' + type: array + security: + - ApiKeyAuth: [] + summary: List all active instances + tags: + - admin + /api/admin/instances/{instance_id}: + delete: parameters: - - description: Action to perform ban/unban - in: query - name: action - required: true - type: string - - description: Id of user - in: query - name: id + - description: Instance ID + in: path + name: instance_id required: true type: string produces: @@ -300,82 +582,140 @@ paths: responses: "200": description: OK - schema: - $ref: '#/definitions/api.ChallengeStatusResp' - "400": - description: Bad Request - schema: - $ref: '#/definitions/api.HTTPPlainResp' - "500": - description: Internal Server Error schema: $ref: '#/definitions/api.HTTPPlainResp' - summary: Ban/Unban a user based on his id and the action provided. + security: + - ApiKeyAuth: [] + summary: Delete an instance by ID tags: - admin - /api/config/competition-info: - post: - consumes: - - application/json - description: Populates beast gobal config map by reparsing the config file $HOME/.beast/config.toml. + get: parameters: - - description: Competition Name - in: formData - name: name + - description: Instance ID + in: path + name: instance_id required: true type: string - - description: Some information about competition - in: formData - name: about + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/api.AdminInstanceResponse' + security: + - ApiKeyAuth: [] + summary: Get an instance by ID + tags: + - admin + /api/admin/instances/challenge/{challenge_name}: + delete: + parameters: + - description: Challenge name + in: path + name: challenge_name required: true type: string - - description: Competitions Prizes for the winners - in: formData - name: prizes - type: string - - description: Competition's starting time - in: formData - name: starting_time + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/api.HTTPPlainResp' + security: + - ApiKeyAuth: [] + summary: Delete all instances for a challenge + tags: + - admin + /api/admin/instances/user/{user_id}: + delete: + parameters: + - description: User ID + in: path + name: user_id required: true type: string - - description: Competition's ending time - in: formData - name: ending_time + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/api.HTTPPlainResp' + security: + - ApiKeyAuth: [] + summary: Delete all instances owned by a user + tags: + - admin + /api/admin/leaderboard: + get: + consumes: + - application/json + description: Returns admin leaderboard of all users + parameters: + - description: Bearer + in: header + name: Authorization required: true type: string - - description: Competition's timezone - in: formData - name: timezone - required: true + - description: Page number + in: query + name: page type: string - - description: Competition's logo - in: formData - name: logo - type: file produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/api.HTTPPlainResp' + $ref: '#/definitions/api.UserResp' "400": description: Bad Request schema: - $ref: '#/definitions/api.HTTPPlainResp' + $ref: '#/definitions/api.HTTPErrorResp' "500": description: Internal Server Error schema: $ref: '#/definitions/api.HTTPErrorResp' - summary: Updates competition info in the beast global configuration, located - at ~/.beast/config.toml. + summary: Returns admin leaderboard tags: - - config - /api/config/reload/: - patch: + - info + /api/admin/statistics: + get: + consumes: + - application/json + description: returns statistics of users in competition (currently limited to + ban/unban status of users) + parameters: + - description: Bearer + in: header + name: Authorization + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/api.UsersStatisticsResp' + "404": + description: Not Found + schema: + $ref: '#/definitions/api.HTTPErrorResp' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.HTTPErrorResp' + summary: statistics of users in competition + tags: + - info + /api/admin/submissions: + get: consumes: - application/json - description: Populates beast gobal config map by reparsing the config file $HOME/.beast/config.toml. + description: Handles submissions made by the user parameters: - description: Bearer in: header @@ -384,6 +724,115 @@ paths: type: string produces: - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/api.SubmissionResp' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.HTTPErrorResp' + summary: Handles submissions made by the user + tags: + - info + /api/admin/unfreezeLeaderboard: + post: + consumes: + - application/json + description: unfreezes the user leaderboard on demand. + parameters: + - description: Bearer + in: header + name: Authorization + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/api.UserResp' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.HTTPErrorResp' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.HTTPErrorResp' + summary: Unfreeze user leaderboard + tags: + - info + /api/admin/users/{action}/{id}: + post: + consumes: + - application/json + description: Ban/Unban/Hide/Unhide a user based on his user id. This operation + can only be done by admins + parameters: + - description: Action to perform Ban/Unban/Hide/Unhide + in: query + name: action + required: true + type: string + - description: Id of user + in: query + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/api.ChallengeStatusResp' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.HTTPPlainResp' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.HTTPPlainResp' + summary: Ban/Unban/Hide/Unhide a user based on his id and the action provided. + tags: + - admin + /api/config/challenge-info: + post: + consumes: + - application/json + description: Updates challenge info in the database, located at ~/.beast/beast.db. + parameters: + - description: Challenge Name + in: formData + name: name + required: true + type: string + - description: Challenge's description + in: formData + name: desc + type: string + - description: Challenge's points + in: formData + name: points + type: string + - description: Challenge's flag + in: formData + name: flag + type: string + - description: Challenge's tags + in: formData + name: tags + type: string + - description: Challenge's ports + in: formData + name: ports + type: string + produces: + - application/json responses: "200": description: OK @@ -393,10 +842,14 @@ paths: description: Bad Request schema: $ref: '#/definitions/api.HTTPPlainResp' - summary: Reloads any changes in beast global configuration, located at ~/.beast/config.toml. + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.HTTPErrorResp' + summary: Updates challenge info in the database, located at ~/.beast/beast.db. tags: - config - /api/info/challenge/info: + /api/info/challenge/{name}: get: consumes: - application/json @@ -408,7 +861,7 @@ paths: required: true type: string - description: Name of challenge - in: query + in: path name: name required: true type: string @@ -418,7 +871,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/api.ChallengeInfoResp' + $ref: '#/definitions/api.Challenge' "400": description: Bad Request schema: @@ -460,7 +913,9 @@ paths: "200": description: OK schema: - $ref: '#/definitions/api.ChallengeInfoResp' + items: + $ref: '#/definitions/api.ChallengeMetadata' + type: array "400": description: Bad Request schema: @@ -469,7 +924,115 @@ paths: description: Internal Server Error schema: $ref: '#/definitions/api.HTTPErrorResp' - summary: Returns information about all challenges with and without filters. + summary: Returns metadata about all challenges with and without filters. + tags: + - info + /api/info/competition-info: + get: + consumes: + - application/json + description: returns various information about the competition which are used + to control competition + parameters: + - description: Bearer + in: header + name: Authorization + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/api.CompetitionInfoResp' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.HTTPErrorResp' + summary: returns competition info + tags: + - info + /api/info/download: + get: + consumes: + - application/json + parameters: + - description: Bearer + in: header + name: Authorization + required: true + type: string + - description: The name of the challenge to get the logs for. + in: query + name: challenge + type: string + - description: The name of the static asset requested. + in: query + name: asset + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/api.LogsInfoResp' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.HTTPPlainResp' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.HTTPPlainResp' + tags: + - info + /api/info/hint/{hintID}: + get: + parameters: + - description: Hint ID + in: path + name: hintID + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/api.HintResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.HTTPErrorResp' + security: + - ApiKeyAuth: [] + summary: Read or purchase a challenge hint + tags: + - info + post: + parameters: + - description: Hint ID + in: path + name: hintID + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/api.HintResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.HTTPErrorResp' + security: + - ApiKeyAuth: [] + summary: Read or purchase a challenge hint tags: - info /api/info/images/available: @@ -495,21 +1058,20 @@ paths: this is a constant specified in beast global config tags: - info - /api/info/logs: + /api/info/leaderboard: get: consumes: - application/json - description: Gives container logs for a particular challenge, useful for debugging - purposes. + description: Returns leaderboard of all users parameters: - description: Bearer in: header name: Authorization required: true type: string - - description: The name of the challenge to get the logs for. + - description: Page number in: query - name: challenge + name: page type: string produces: - application/json @@ -517,25 +1079,45 @@ paths: "200": description: OK schema: - $ref: '#/definitions/api.LogsInfoResp' + $ref: '#/definitions/api.UserResp' "400": description: Bad Request schema: - $ref: '#/definitions/api.HTTPPlainResp' + $ref: '#/definitions/api.HTTPErrorResp' "500": description: Internal Server Error schema: - $ref: '#/definitions/api.HTTPPlainResp' - summary: Handles route related to logs handling of container + $ref: '#/definitions/api.HTTPErrorResp' + summary: Returns leaderboard tags: - info - /api/info/ports/used: + /api/info/leaderboard-graph: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/database.UserLeaderboardResp' + type: array + security: + - ApiKeyAuth: [] + summary: Return leaderboard score history + tags: + - info + /api/info/submissions/challenge/{challenge_id}: get: consumes: - application/json - description: Returns the ports in use by beast, which cannot be used in creating - a new challenge.. + description: Returns all user attempts for a given challenge. parameters: + - description: Challenge ID + in: path + name: challenge_id + required: true + type: integer - description: Bearer in: header name: Authorization @@ -547,18 +1129,31 @@ paths: "200": description: OK schema: - $ref: '#/definitions/api.PortsInUseResp' - summary: Returns ports in use by beast by looking in the hack git repository, - also returns min and max value of port allowed while specifying in beast challenge - config. + items: + $ref: '#/definitions/api.UserSolveResp' + type: array + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.HTTPErrorResp' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.HTTPErrorResp' + summary: Get challenge attempts tags: - info - /api/info/submissions: + /api/info/submissions/user/{user_id}: get: consumes: - application/json - description: Handles submissions made by the user + description: Returns all submissions for a specific user parameters: + - description: User ID + in: path + name: user_id + required: true + type: integer - description: Bearer in: header name: Authorization @@ -570,15 +1165,50 @@ paths: "200": description: OK schema: - $ref: '#/definitions/api.SubmissionResp' + items: + $ref: '#/definitions/api.SubmissionResp' + type: array + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.HTTPErrorResp' + "404": + description: Not Found + schema: + $ref: '#/definitions/api.HTTPErrorResp' "500": description: Internal Server Error schema: $ref: '#/definitions/api.HTTPErrorResp' - summary: Handles submissions made by the user + summary: Get submissions by user tags: - info - /api/info/user: + /api/info/tags: + get: + consumes: + - application/json + description: returns all unique tags + parameters: + - description: Bearer + in: header + name: Authorization + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/api.TagInfoResp' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.HTTPErrorResp' + summary: returns all tags + tags: + - info + /api/info/user/{username}: get: consumes: - application/json @@ -589,13 +1219,233 @@ paths: name: Authorization required: true type: string - - description: User's id + - description: User ID + in: query + name: user_id + type: integer + - description: Username + in: path + name: username + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/api.UserResp' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.HTTPErrorResp' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.HTTPErrorResp' + summary: Returns user info + tags: + - info + /api/info/usercount: + get: + consumes: + - application/json + description: Returns the number of users in the database with role=contestant + parameters: + - description: Bearer + in: header + name: Authorization + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/api.UserCountResp' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.HTTPErrorResp' + summary: Returns the number of users in the database with role=contestant + tags: + - info + /api/info/users: + get: + consumes: + - application/json + description: Returns all available user's info + parameters: + - description: Bearer + in: header + name: Authorization + required: true + type: string + - description: Sort by username or score + in: query + name: sort + type: string + - description: 'Score order: asc or desc' + in: query + name: order + type: string + - description: Filter by banned, active, or hidden + in: query + name: filter + type: string + - description: 'Response format: json or csv' + in: query + name: format + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/api.UsersResp' + type: array + "404": + description: Not Found + schema: + $ref: '#/definitions/api.HTTPErrorResp' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.HTTPErrorResp' + summary: Returns all user's info + tags: + - info + /api/instances: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/api.InstanceResponse' + type: array + security: + - ApiKeyAuth: [] + summary: List the current user's instances + tags: + - instances + /api/instances/{challenge_name}: + delete: + parameters: + - description: Challenge name + in: path + name: challenge_name + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/api.HTTPPlainResp' + security: + - ApiKeyAuth: [] + summary: Delete the current user's challenge instance + tags: + - instances + get: + parameters: + - description: Challenge name + in: path + name: challenge_name + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/api.InstanceResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/api.HTTPErrorResp' + security: + - ApiKeyAuth: [] + summary: Get the current user's challenge instance + tags: + - instances + /api/instances/{challenge_name}/extend: + post: + parameters: + - description: Challenge name + in: path + name: challenge_name + required: true + type: string + - description: Requested extension in seconds in: formData - name: value + name: seconds + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/api.InstanceResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.HTTPErrorResp' + security: + - ApiKeyAuth: [] + summary: Extend the current user's challenge instance + tags: + - instances + /api/instances/{challenge_name}/spawn: + post: + parameters: + - description: Challenge name + in: path + name: challenge_name + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/api.InstanceResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.HTTPErrorResp' + security: + - ApiKeyAuth: [] + summary: Spawn a per-user challenge instance + tags: + - instances + /api/manage/challenge/: + post: + consumes: + - application/json + description: Handles challenge management routes with actions which includes + - DEPLOY, UNDEPLOY, PURGE. + parameters: + - description: Name of the challenge to be managed, here name is the unique + identifier for challenge + in: query + name: name + required: true type: string - - description: username + - description: Action for the challenge in: query - name: value + name: action + required: true type: string produces: - application/json @@ -603,56 +1453,59 @@ paths: "200": description: OK schema: - $ref: '#/definitions/api.UserResp' + $ref: '#/definitions/api.HTTPPlainResp' "400": description: Bad Request schema: - $ref: '#/definitions/api.HTTPErrorResp' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/api.HTTPErrorResp' - summary: Returns user info + $ref: '#/definitions/api.HTTPPlainResp' + summary: Handles challenge management actions. tags: - - info - /api/info/user/available: - get: + - manage + /api/manage/challenge/{name}/exec: + post: consumes: - application/json - description: Returns all available user's info parameters: - - description: Bearer - in: header - name: Authorization + - description: Challenge name + in: path + name: name required: true type: string + - description: Bounded exec request + in: body + name: request + required: true + schema: + $ref: '#/definitions/api.ExecChallengeRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/api.UserResp' - "404": - description: Not Found + $ref: '#/definitions/api.ExecChallengeResponse' + "400": + description: Bad Request schema: $ref: '#/definitions/api.HTTPErrorResp' - "500": - description: Internal Server Error + "403": + description: Forbidden schema: $ref: '#/definitions/api.HTTPErrorResp' - summary: Returns all user's info + security: + - ApiKeyAuth: [] + summary: Execute an argument-vector command in an owned challenge container tags: - - info - /api/manage/challenge/: + - manage + /api/manage/challenge/multiple/: post: consumes: - application/json description: Handles challenge management routes with actions which includes - - DEPLOY, UNDEPLOY, PURGE. + - DEPLOY, UNDEPLOY, PURGE of multiple challenges. parameters: - description: Name of the challenge to be managed, here name is the unique - identifier for challenge + identifier for challenges seperated by a comma in: query name: name required: true @@ -673,17 +1526,15 @@ paths: description: Bad Request schema: $ref: '#/definitions/api.HTTPPlainResp' - summary: Handles challenge management actions. - tags: - - manage + summary: Handles multiple challenge management actions. /api/manage/challenge/upload: post: consumes: - application/json - description: Handles the challenge management from a challenge in tar file. - Currently prepare the tar file + description: Handles the challenge management from a challenge in zip file. + Currently prepare the zip file parameters: - - description: .tar file to be uploaded to fetch challenge info + - description: .zip file to be uploaded to fetch challenge info in: formData name: file required: true @@ -703,7 +1554,62 @@ paths: description: Internal Server Error schema: $ref: '#/definitions/api.HTTPErrorResp' - summary: Untar and fetch info from beast.toml file in challenge + summary: Unzip and fetch info from beast.toml file in challenge + tags: + - manage + /api/manage/challenge/validateflag: + post: + parameters: + - description: Challenge name + in: formData + name: challenge_name + required: true + type: string + - description: Flag to validate + in: formData + name: flag + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/api.HTTPPlainResp' + "403": + description: Forbidden + schema: + $ref: '#/definitions/api.HTTPErrorResp' + security: + - ApiKeyAuth: [] + summary: Validate a configured challenge flag as its manager + tags: + - manage + /api/manage/challenge/verify: + post: + consumes: + - application/json + description: Commits the challenge container for later use + parameters: + - description: Name of the challenge to commit + in: query + name: challenge + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/api.HTTPPlainResp' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.HTTPPlainResp' + summary: Commits the challenge container so that later the challenge image can + be used deployment tags: - manage /api/manage/commit/: @@ -729,7 +1635,7 @@ paths: can be deployed or not. tags: - manage - /api/manage/deploy/local: + /api/manage/deploy/local/: post: consumes: - application/json @@ -759,7 +1665,43 @@ paths: summary: Deploy a local challenge using the path provided in the post parameter tags: - manage - /api/manage/multiple/:action: + /api/manage/logs: + get: + consumes: + - application/json + description: Gives container logs for a particular challenge, useful for debugging + purposes. + parameters: + - description: Bearer + in: header + name: Authorization + required: true + type: string + - description: The name of the challenge to get the logs for. + in: query + name: challenge + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/api.LogsInfoResp' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.HTTPPlainResp' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.HTTPPlainResp' + security: + - ApiKeyAuth: [] + summary: Handles route related to logs handling of container + tags: + - info + /api/manage/multiple/{action}: post: consumes: - application/json @@ -789,7 +1731,7 @@ paths: summary: Handles challenge management actions for multiple challenges. tags: - manage - /api/manage/schedule/:action: + /api/manage/schedule/{action}: post: consumes: - application/json @@ -839,7 +1781,7 @@ paths: summary: Schedule an action(deploy, undeploy, purge etc.) on a particular challenge tags: - manage - /api/manage/static/:action: + /api/manage/static/{action}: post: consumes: - application/json @@ -900,7 +1842,7 @@ paths: tags: - notification /api/notification/available: - post: + get: consumes: - application/json description: Fetch all the notifications from database @@ -919,7 +1861,7 @@ paths: tags: - notification /api/notification/delete: - post: + delete: consumes: - application/json description: Removes notifications @@ -947,8 +1889,22 @@ paths: summary: Removes notifications tags: - notification + /api/notification/stream: + get: + produces: + - text/event-stream + responses: + "200": + description: SSE stream + schema: + type: string + security: + - ApiKeyAuth: [] + summary: Stream server-sent notifications + tags: + - notification /api/notification/update: - post: + put: consumes: - application/json description: Updates any changes in the notifications @@ -986,7 +1942,7 @@ paths: summary: Updates notifications tags: - notification - /api/remote/reset/: + /api/remote/reset: post: consumes: - application/json @@ -1012,7 +1968,7 @@ paths: summary: Resets beast local copy of remote git repository. tags: - remote - /api/remote/sync/: + /api/remote/sync: post: consumes: - application/json @@ -1038,7 +1994,45 @@ paths: summary: Syncs beast's local copy of remote git repository for challenges. tags: - remote - /api/status/all/:filter: + /api/status/all: + get: + consumes: + - application/json + description: This returns the challenges in the status provided, along with + their name and last updated time. + parameters: + - description: Status type to filter with, if none specified then all + in: query + name: filter + required: true + type: string + - description: Bearer + in: header + name: Authorization + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/api.ChallengeStatusResp' + type: array + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.HTTPPlainResp' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.HTTPPlainResp' + summary: Returns challenge deployment status from the beast database for the + challenges which matches the stauts according to filter. + tags: + - status + /api/status/all/{filter}: get: consumes: - application/json @@ -1076,7 +2070,7 @@ paths: challenges which matches the stauts according to filter. tags: - status - /api/status/challenge/:name: + /api/status/challenge/{name}: get: consumes: - application/json @@ -1212,10 +2206,6 @@ paths: name: email required: true type: string - - description: User's ssh-key - in: formData - name: ssh-key - type: string produces: - application/json responses: @@ -1261,6 +2251,114 @@ paths: summary: Resets password for the user tags: - auth + /auth/send-otp: + post: + consumes: + - multipart/form-data + parameters: + - description: Email address + in: formData + name: email + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/api.HTTPPlainResp' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.HTTPErrorResp' + summary: Send an email verification OTP + tags: + - auth + /auth/send-otp-forget: + post: + consumes: + - multipart/form-data + parameters: + - description: Registered email address + in: formData + name: email + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/api.HTTPPlainResp' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.HTTPErrorResp' + summary: Send a password-reset OTP + tags: + - auth + /auth/verify-otp: + post: + consumes: + - multipart/form-data + parameters: + - description: Email address + in: formData + name: email + required: true + type: string + - description: One-time code + in: formData + name: otp + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/api.HTTPPlainResp' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.HTTPErrorResp' + summary: Verify an email OTP + tags: + - auth + /auth/verify-otp-forget: + post: + consumes: + - multipart/form-data + parameters: + - description: Registered email address + in: formData + name: email + required: true + type: string + - description: One-time code + in: formData + name: otp + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/api.HTTPAuthorizeResp' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.HTTPErrorResp' + summary: Verify a password-reset OTP + tags: + - auth +schemes: +- https securityDefinitions: ApiKeyAuth: in: header diff --git a/api/docs/swagger/swagger.json b/api/docs/swagger/swagger.json deleted file mode 100644 index 2294d64b..00000000 --- a/api/docs/swagger/swagger.json +++ /dev/null @@ -1,1850 +0,0 @@ -{ - "swagger": "2.0", - "info": { - "description": "Beast the automatic deployment tool for backdoor", - "title": "Beast API", - "contact": { - "name": "SDSLabs", - "url": "https://chat.sdslabs.co", - "email": "contact.sdslabs.co.in" - }, - "license": { - "name": "Apache 2.0", - "url": "http://www.apache.org/licenses/LICENSE-2.0.html" - }, - "version": "1.0" - }, - "host": "beast.sdslabs.co", - "basePath": "/", - "paths": { - "/api/admin/statistics": { - "get": { - "description": "returns various information about the competition which are used to control competition", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "info" - ], - "summary": "returns competition info", - "parameters": [ - { - "type": "string", - "description": "Bearer", - "name": "Authorization", - "in": "header", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/api.CompetitionInfoResp" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/api.HTTPErrorResp" - } - } - } - } - }, - "/api/admin/users/:action/:id": { - "post": { - "description": "Ban/unban a user based on his user id. This operation can only be done by admins", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "Ban/Unban a user based on his id and the action provided.", - "parameters": [ - { - "type": "string", - "description": "Action to perform ban/unban", - "name": "action", - "in": "query", - "required": true - }, - { - "type": "string", - "description": "Id of user", - "name": "id", - "in": "query", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/api.ChallengeStatusResp" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/api.HTTPPlainResp" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/api.HTTPPlainResp" - } - } - } - } - }, - "/api/config/competition-info": { - "post": { - "description": "Populates beast gobal config map by reparsing the config file $HOME/.beast/config.toml.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "config" - ], - "summary": "Updates competition info in the beast global configuration, located at ~/.beast/config.toml.", - "parameters": [ - { - "type": "string", - "description": "Competition Name", - "name": "name", - "in": "formData", - "required": true - }, - { - "type": "string", - "description": "Some information about competition", - "name": "about", - "in": "formData", - "required": true - }, - { - "type": "string", - "description": "Competitions Prizes for the winners", - "name": "prizes", - "in": "formData" - }, - { - "type": "string", - "description": "Competition's starting time", - "name": "starting_time", - "in": "formData", - "required": true - }, - { - "type": "string", - "description": "Competition's ending time", - "name": "ending_time", - "in": "formData", - "required": true - }, - { - "type": "string", - "description": "Competition's timezone", - "name": "timezone", - "in": "formData", - "required": true - }, - { - "type": "file", - "description": "Competition's logo", - "name": "logo", - "in": "formData" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/api.HTTPPlainResp" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/api.HTTPPlainResp" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/api.HTTPErrorResp" - } - } - } - } - }, - "/api/config/reload/": { - "patch": { - "description": "Populates beast gobal config map by reparsing the config file $HOME/.beast/config.toml.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "config" - ], - "summary": "Reloads any changes in beast global configuration, located at ~/.beast/config.toml.", - "parameters": [ - { - "type": "string", - "description": "Bearer", - "name": "Authorization", - "in": "header", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/api.HTTPPlainResp" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/api.HTTPPlainResp" - } - } - } - } - }, - "/api/info/challenge/info": { - "get": { - "description": "Returns all information about the challenges by the challenge name.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "info" - ], - "summary": "Returns all information about the challenges.", - "parameters": [ - { - "type": "string", - "description": "Bearer", - "name": "Authorization", - "in": "header", - "required": true - }, - { - "type": "string", - "description": "Name of challenge", - "name": "name", - "in": "query", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/api.ChallengeInfoResp" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/api.HTTPErrorResp" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/api.HTTPErrorResp" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/api.HTTPErrorResp" - } - } - } - } - }, - "/api/info/challenges": { - "get": { - "description": "Returns information about all the challenges present in the database with and without filters.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "info" - ], - "summary": "Returns information about all challenges with and without filters.", - "parameters": [ - { - "type": "string", - "description": "Filter parameter by which challenges are filtered", - "name": "filter", - "in": "query" - }, - { - "type": "string", - "description": "Value of filtered parameter", - "name": "value", - "in": "query" - }, - { - "type": "string", - "description": "Bearer", - "name": "Authorization", - "in": "header", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/api.ChallengeInfoResp" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/api.HTTPErrorResp" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/api.HTTPErrorResp" - } - } - } - } - }, - "/api/info/images/available": { - "get": { - "description": "Returns all the available base images which can be used for challenge creation as the base OS for challenge.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "info" - ], - "summary": "Gives all the base images that can be used while creating a beast challenge, this is a constant specified in beast global config", - "parameters": [ - { - "type": "string", - "description": "Bearer", - "name": "Authorization", - "in": "header", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/api.AvailableImagesResp" - } - } - } - } - }, - "/api/info/logs": { - "get": { - "description": "Gives container logs for a particular challenge, useful for debugging purposes.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "info" - ], - "summary": "Handles route related to logs handling of container", - "parameters": [ - { - "type": "string", - "description": "Bearer", - "name": "Authorization", - "in": "header", - "required": true - }, - { - "type": "string", - "description": "The name of the challenge to get the logs for.", - "name": "challenge", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/api.LogsInfoResp" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/api.HTTPPlainResp" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/api.HTTPPlainResp" - } - } - } - } - }, - "/api/info/ports/used": { - "get": { - "description": "Returns the ports in use by beast, which cannot be used in creating a new challenge..", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "info" - ], - "summary": "Returns ports in use by beast by looking in the hack git repository, also returns min and max value of port allowed while specifying in beast challenge config.", - "parameters": [ - { - "type": "string", - "description": "Bearer", - "name": "Authorization", - "in": "header", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/api.PortsInUseResp" - } - } - } - } - }, - "/api/info/submissions": { - "get": { - "description": "Handles submissions made by the user", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "info" - ], - "summary": "Handles submissions made by the user", - "parameters": [ - { - "type": "string", - "description": "Bearer", - "name": "Authorization", - "in": "header", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/api.SubmissionResp" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/api.HTTPErrorResp" - } - } - } - } - }, - "/api/info/user": { - "get": { - "description": "Returns user info based on userId", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "info" - ], - "summary": "Returns user info", - "parameters": [ - { - "type": "string", - "description": "Bearer", - "name": "Authorization", - "in": "header", - "required": true - }, - { - "type": "string", - "description": "User's id", - "name": "value", - "in": "formData" - }, - { - "type": "string", - "description": "username", - "name": "value", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/api.UserResp" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/api.HTTPErrorResp" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/api.HTTPErrorResp" - } - } - } - } - }, - "/api/info/user/available": { - "get": { - "description": "Returns all available user's info", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "info" - ], - "summary": "Returns all user's info", - "parameters": [ - { - "type": "string", - "description": "Bearer", - "name": "Authorization", - "in": "header", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/api.UserResp" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/api.HTTPErrorResp" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/api.HTTPErrorResp" - } - } - } - } - }, - "/api/manage/challenge/": { - "post": { - "description": "Handles challenge management routes with actions which includes - DEPLOY, UNDEPLOY, PURGE.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "manage" - ], - "summary": "Handles challenge management actions.", - "parameters": [ - { - "type": "string", - "description": "Name of the challenge to be managed, here name is the unique identifier for challenge", - "name": "name", - "in": "query", - "required": true - }, - { - "type": "string", - "description": "Action for the challenge", - "name": "action", - "in": "query", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/api.HTTPPlainResp" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/api.HTTPPlainResp" - } - } - } - } - }, - "/api/manage/challenge/upload": { - "post": { - "description": "Handles the challenge management from a challenge in tar file. Currently prepare the tar file", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "manage" - ], - "summary": "Untar and fetch info from beast.toml file in challenge", - "parameters": [ - { - "type": "file", - "description": ".tar file to be uploaded to fetch challenge info", - "name": "file", - "in": "formData", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/api.ChallengePreviewResp" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/api.HTTPErrorResp" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/api.HTTPErrorResp" - } - } - } - } - }, - "/api/manage/commit/": { - "post": { - "description": "Validates challenge configuration for deployment.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "manage" - ], - "summary": "Validates the configuration of the challenge and tells if challenge can be deployed or not.", - "parameters": [ - { - "type": "string", - "description": "Name of the challenge to verify the deployment configuration for.", - "name": "challenge", - "in": "query", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/api.HTTPErrorResp" - } - } - } - } - }, - "/api/manage/deploy/local": { - "post": { - "description": "Handles deployment of a challenge using the absolute directory path", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "manage" - ], - "summary": "Deploy a local challenge using the path provided in the post parameter", - "parameters": [ - { - "type": "string", - "description": "Challenge Directory", - "name": "challenge_dir", - "in": "query", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/api.HTTPPlainResp" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/api.HTTPPlainResp" - } - }, - "406": { - "description": "Not Acceptable", - "schema": { - "$ref": "#/definitions/api.HTTPPlainResp" - } - } - } - } - }, - "/api/manage/multiple/:action": { - "post": { - "description": "Handles challenge management routes for multiple the challenges with actions which includes - DEPLOY, UNDEPLOY.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "manage" - ], - "summary": "Handles challenge management actions for multiple challenges.", - "parameters": [ - { - "type": "string", - "description": "Action for the challenge", - "name": "action", - "in": "query", - "required": true - }, - { - "type": "string", - "description": "Tag for a group of challenges", - "name": "tag", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/api.HTTPPlainResp" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/api.HTTPPlainResp" - } - } - } - } - }, - "/api/manage/schedule/:action": { - "post": { - "description": "Handles scheduleing of challenge action to executed at some later point of time", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "manage" - ], - "summary": "Schedule an action(deploy, undeploy, purge etc.) on a particular challenge", - "parameters": [ - { - "type": "string", - "description": "Bearer", - "name": "Authorization", - "in": "header", - "required": true - }, - { - "type": "string", - "description": "Action for the underlying challenge in context", - "name": "action", - "in": "query", - "required": true - }, - { - "type": "string", - "description": "The name of the challenge to schedule the action for.", - "name": "challenge", - "in": "query" - }, - { - "type": "string", - "description": "Tag corresponding to challenges in context, optional if challenge name is provided", - "name": "tags", - "in": "query" - }, - { - "type": "string", - "description": "Timestamp at which the challenge should be scheduled should be a unix timestamp string.", - "name": "at", - "in": "query" - }, - { - "type": "string", - "description": "Time after which the action on the selector should be executed should be of duration format as in '1m20s' etc.", - "name": "after", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/api.HTTPPlainResp" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/api.HTTPPlainResp" - } - } - } - } - }, - "/api/manage/static/:action": { - "post": { - "description": "Handles beast static content serving container routes.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "manage" - ], - "summary": "Handles route related to beast static content serving container, takes action as route parameter and perform that action", - "parameters": [ - { - "type": "string", - "description": "Action to apply on the beast static content provider", - "name": "action", - "in": "query", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/api.HTTPPlainResp" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/api.HTTPPlainResp" - } - } - } - } - }, - "/api/notification/add": { - "post": { - "description": "Adds notifications", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "notification" - ], - "summary": "Adds notifications", - "parameters": [ - { - "type": "string", - "description": "Title of notification to be added", - "name": "title", - "in": "formData", - "required": true - }, - { - "type": "string", - "description": "Description for the notification to be added", - "name": "desc", - "in": "formData", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/api.HTTPPlainResp" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/api.HTTPErrorResp" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/api.HTTPErrorResp" - } - } - } - } - }, - "/api/notification/available": { - "post": { - "description": "Fetch all the notifications from database", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "notification" - ], - "summary": "Fetch available notifications", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/api.HTTPPlainResp" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/api.HTTPErrorResp" - } - } - } - } - }, - "/api/notification/delete": { - "post": { - "description": "Removes notifications", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "notification" - ], - "summary": "Removes notifications", - "parameters": [ - { - "type": "string", - "description": "Title of notification", - "name": "id", - "in": "formData", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/api.HTTPPlainResp" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/api.HTTPErrorResp" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/api.HTTPErrorResp" - } - } - } - } - }, - "/api/notification/update": { - "post": { - "description": "Updates any changes in the notifications", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "notification" - ], - "summary": "Updates notifications", - "parameters": [ - { - "type": "string", - "description": "Title of notification", - "name": "id", - "in": "formData", - "required": true - }, - { - "type": "string", - "description": "Title of notification", - "name": "title", - "in": "formData", - "required": true - }, - { - "type": "string", - "description": "Description for the notification to be changed", - "name": "desc", - "in": "formData", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/api.HTTPPlainResp" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/api.HTTPErrorResp" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/api.HTTPErrorResp" - } - } - } - } - }, - "/api/remote/reset/": { - "post": { - "description": "Resets local copy of remote git directory, it first deletes the existing directory and then clone from the remote again.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "remote" - ], - "summary": "Resets beast local copy of remote git repository.", - "parameters": [ - { - "type": "string", - "description": "Bearer", - "name": "Authorization", - "in": "header", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/api.HTTPPlainResp" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/api.HTTPPlainResp" - } - } - } - } - }, - "/api/remote/sync/": { - "post": { - "description": "Syncs beasts local challenges database with the remote git repository(hack) the local copy of the challenge database is located at $HOME/.beast/remote/$REMOTE_NAME.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "remote" - ], - "summary": "Syncs beast's local copy of remote git repository for challenges.", - "parameters": [ - { - "type": "string", - "description": "Bearer", - "name": "Authorization", - "in": "header", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/api.HTTPPlainResp" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/api.HTTPPlainResp" - } - } - } - } - }, - "/api/status/all/:filter": { - "get": { - "description": "This returns the challenges in the status provided, along with their name and last updated time.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "status" - ], - "summary": "Returns challenge deployment status from the beast database for the challenges which matches the stauts according to filter.", - "parameters": [ - { - "type": "string", - "description": "Status type to filter with, if none specified then all", - "name": "filter", - "in": "query", - "required": true - }, - { - "type": "string", - "description": "Bearer", - "name": "Authorization", - "in": "header", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/api.ChallengeStatusResp" - } - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/api.HTTPPlainResp" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/api.HTTPPlainResp" - } - } - } - } - }, - "/api/status/challenge/:name": { - "get": { - "description": "Returns challenge deployment status from the beast database, for those challenges which are not present a status value NA is returned.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "status" - ], - "summary": "Returns challenge deployment status from the beast database.", - "parameters": [ - { - "type": "string", - "description": "Name of the challenge", - "name": "name", - "in": "query", - "required": true - }, - { - "type": "string", - "description": "Bearer", - "name": "Authorization", - "in": "header", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/api.ChallengeStatusResp" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/api.HTTPPlainResp" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/api.HTTPPlainResp" - } - } - } - } - }, - "/api/submit/challenge": { - "post": { - "description": "Returns success or error response based on the flag submitted. Also, the flag will not be submitted if it was previously submitted", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Submit" - ], - "summary": "Verifies and creates an entry in the database for successful submission of flag for a challenge.", - "parameters": [ - { - "type": "string", - "description": "Name of challenge", - "name": "chall_id", - "in": "formData", - "required": true - }, - { - "type": "string", - "description": "Flag for the challenge", - "name": "flag", - "in": "formData", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/api.ChallengeStatusResp" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/api.HTTPPlainResp" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/api.HTTPPlainResp" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/api.HTTPPlainResp" - } - } - } - } - }, - "/auth/login": { - "post": { - "description": "JWT can be received by signing in", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "auth" - ], - "summary": "Handles signin and token production", - "parameters": [ - { - "type": "string", - "description": "Username", - "name": "username", - "in": "formData", - "required": true - }, - { - "type": "string", - "description": "Password", - "name": "password", - "in": "formData", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/api.HTTPAuthorizeResp" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/api.HTTPPlainResp" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/api.HTTPPlainResp" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/api.HTTPPlainResp" - } - } - } - } - }, - "/auth/register": { - "post": { - "description": "Signup route for the user", - "produces": [ - "application/json" - ], - "tags": [ - "auth" - ], - "summary": "Signup for the user", - "parameters": [ - { - "type": "string", - "description": "User's name", - "name": "name", - "in": "formData" - }, - { - "type": "string", - "description": "Username", - "name": "username", - "in": "formData", - "required": true - }, - { - "type": "string", - "description": "Password", - "name": "password", - "in": "formData", - "required": true - }, - { - "type": "string", - "description": "User's email id", - "name": "email", - "in": "formData", - "required": true - }, - { - "type": "string", - "description": "User's ssh-key", - "name": "ssh-key", - "in": "formData" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/api.HTTPPlainResp" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/api.HTTPPlainResp" - } - }, - "406": { - "description": "Not Acceptable", - "schema": { - "$ref": "#/definitions/api.HTTPPlainResp" - } - } - } - } - }, - "/auth/reset-password": { - "post": { - "description": "Resets password for the user", - "produces": [ - "application/json" - ], - "tags": [ - "auth" - ], - "summary": "Resets password for the user", - "parameters": [ - { - "type": "string", - "description": "New Password", - "name": "new_pass", - "in": "formData", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/api.HTTPPlainResp" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/api.HTTPPlainResp" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/api.HTTPPlainResp" - } - } - } - } - } - }, - "definitions": { - "api.AvailableImagesResp": { - "type": "object", - "properties": { - "images": { - "type": "array", - "items": { - "type": "string" - }, - "example": [ - "['ubuntu16.04'", - " 'ubuntu18.04']" - ] - }, - "message": { - "type": "string", - "example": "Available Base images." - } - } - }, - "api.ChallengeInfoResp": { - "type": "object", - "properties": { - "category": { - "type": "string", - "example": "web" - }, - "createdAt": { - "type": "string" - }, - "description": { - "type": "string" - }, - "hints": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "name": { - "type": "string", - "example": "Web Challenge" - }, - "points": { - "type": "integer", - "example": 50 - }, - "ports": { - "type": "array", - "items": { - "type": "integer" - } - }, - "solves": { - "type": "array", - "items": { - "$ref": "#/definitions/api.UserSolveResp" - } - }, - "solvesNumber": { - "type": "integer", - "example": 100 - }, - "status": { - "type": "string", - "example": "deployed" - } - } - }, - "api.ChallengePreviewResp": { - "type": "object", - "properties": { - "category": { - "type": "string", - "example": "web" - }, - "description": { - "type": "string" - }, - "hints": { - "type": "array", - "items": { - "type": "string" - } - }, - "name": { - "type": "string", - "example": "Web Challenge" - }, - "points": { - "type": "integer", - "example": 50 - }, - "ports": { - "type": "array", - "items": { - "type": "integer" - } - } - } - }, - "api.ChallengeSolveResp": { - "type": "object", - "properties": { - "category": { - "type": "string", - "example": "web" - }, - "id": { - "type": "integer", - "example": 4 - }, - "name": { - "type": "string", - "example": "Web Challenge" - }, - "points": { - "type": "integer", - "example": 50 - }, - "solvedAt": { - "type": "string" - } - } - }, - "api.ChallengeStatusResp": { - "type": "object", - "properties": { - "name": { - "type": "string", - "example": "Web Challenge" - }, - "status": { - "type": "string", - "example": "deployed" - }, - "updated_at": { - "type": "string", - "example": "2018-12-31T22:20:08.948096189+05:30" - } - } - }, - "api.CompetitionInfoResp": { - "type": "object", - "properties": { - "about": { - "type": "string", - "example": "This is a CTF competition" - }, - "ending_time": { - "type": "string" - }, - "logo_url": { - "type": "string" - }, - "name": { - "type": "string", - "example": "fristonio" - }, - "prizes": { - "type": "string", - "example": "1st and 2nd place winners will get $10K" - }, - "starting_time": { - "type": "string" - }, - "timezone": { - "type": "string" - } - } - }, - "api.HTTPAuthorizeResp": { - "type": "object", - "properties": { - "message": { - "type": "string", - "example": "Response message" - }, - "role": { - "type": "string", - "example": "author" - }, - "token": { - "type": "string", - "example": "YOUR_AUTHENTICATION_TOKEN" - } - } - }, - "api.HTTPErrorResp": { - "type": "object", - "properties": { - "error": { - "type": "string", - "example": "Error occured while veifying the challenge." - } - } - }, - "api.HTTPPlainResp": { - "type": "object", - "properties": { - "message": { - "type": "string", - "example": "Messsage in response to your request" - } - } - }, - "api.LogsInfoResp": { - "type": "object", - "properties": { - "stderr": { - "type": "string", - "example": "[ERROR] Challenge deployment failed." - }, - "stdout": { - "type": "string", - "example": "[INFO] Challenge is starting to deploy" - } - } - }, - "api.PortsInUseResp": { - "type": "object", - "properties": { - "port_max_value": { - "type": "integer", - "example": 20000 - }, - "port_min_value": { - "type": "integer", - "example": 10000 - }, - "ports_in_use": { - "type": "array", - "items": { - "type": "integer" - } - } - } - }, - "api.SubmissionResp": { - "type": "object", - "properties": { - "category": { - "type": "string", - "example": "web" - }, - "chall_id": { - "type": "integer", - "example": 3 - }, - "name": { - "type": "string", - "example": "Web Challenge" - }, - "points": { - "type": "integer", - "example": 50 - }, - "solvedAt": { - "type": "string" - }, - "user_id": { - "type": "integer", - "example": 3 - }, - "username": { - "type": "string", - "example": "fristonio" - }, - "flag": { - "type": "string", - "example": "flag{@#$}" - } - } - }, - "api.UserResp": { - "type": "object", - "properties": { - "challenges": { - "type": "array", - "items": { - "$ref": "#/definitions/api.ChallengeSolveResp" - } - }, - "email": { - "type": "string", - "example": "fristonio@gmail.com" - }, - "id": { - "type": "integer", - "example": 5 - }, - "rank": { - "type": "integer", - "example": 15 - }, - "role": { - "type": "string", - "example": "author" - }, - "score": { - "type": "integer", - "example": 750 - }, - "status": { - "type": "integer", - "example": 0 - }, - "username": { - "type": "string", - "example": "CTF is live now!" - } - } - }, - "api.UserSolveResp": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "example": 5 - }, - "solvedAt": { - "type": "string" - }, - "username": { - "type": "string", - "example": "fristonio" - } - } - }, - "api.UsersStatisticsResp": { - "type": "object", - "properties": { - "banned_users": { - "type": "integer" - }, - "total_registered_users": { - "type": "integer", - "example": 120 - }, - "unbanned_users": { - "type": "integer" - } - } - } - }, - "securityDefinitions": { - "ApiKeyAuth": { - "type": "apiKey", - "name": "Authorization", - "in": "header" - } - } -} \ No newline at end of file diff --git a/api/docs/swagger/swagger.yaml b/api/docs/swagger/swagger.yaml deleted file mode 100644 index f81d0b2e..00000000 --- a/api/docs/swagger/swagger.yaml +++ /dev/null @@ -1,1269 +0,0 @@ -basePath: / -definitions: - api.AvailableImagesResp: - properties: - images: - example: - - '[''ubuntu16.04''' - - ' ''ubuntu18.04'']' - items: - type: string - type: array - message: - example: Available Base images. - type: string - type: object - api.ChallengeInfoResp: - properties: - category: - example: web - type: string - createdAt: - type: string - description: - type: string - hints: - type: string - id: - type: integer - name: - example: Web Challenge - type: string - points: - example: 50 - type: integer - ports: - items: - type: integer - type: array - solves: - items: - $ref: '#/definitions/api.UserSolveResp' - type: array - solvesNumber: - example: 100 - type: integer - status: - example: deployed - type: string - type: object - api.ChallengePreviewResp: - properties: - category: - example: web - type: string - description: - type: string - hints: - items: - type: string - type: array - name: - example: Web Challenge - type: string - points: - example: 50 - type: integer - ports: - items: - type: integer - type: array - type: object - api.ChallengeSolveResp: - properties: - category: - example: web - type: string - id: - example: 4 - type: integer - name: - example: Web Challenge - type: string - points: - example: 50 - type: integer - solvedAt: - type: string - type: object - api.ChallengeStatusResp: - properties: - name: - example: Web Challenge - type: string - status: - example: deployed - type: string - updated_at: - example: "2018-12-31T22:20:08.948096189+05:30" - type: string - type: object - api.CompetitionInfoResp: - properties: - about: - example: This is a CTF competition - type: string - ending_time: - type: string - logo_url: - type: string - name: - example: fristonio - type: string - prizes: - example: 1st and 2nd place winners will get $10K - type: string - starting_time: - type: string - timezone: - type: string - type: object - api.HTTPAuthorizeResp: - properties: - message: - example: Response message - type: string - role: - example: author - type: string - token: - example: YOUR_AUTHENTICATION_TOKEN - type: string - type: object - api.HTTPErrorResp: - properties: - error: - example: Error occured while veifying the challenge. - type: string - type: object - api.HTTPPlainResp: - properties: - message: - example: Messsage in response to your request - type: string - type: object - api.LogsInfoResp: - properties: - stderr: - example: '[ERROR] Challenge deployment failed.' - type: string - stdout: - example: '[INFO] Challenge is starting to deploy' - type: string - type: object - api.PortsInUseResp: - properties: - port_max_value: - example: 20000 - type: integer - port_min_value: - example: 10000 - type: integer - ports_in_use: - items: - type: integer - type: array - type: object - api.SubmissionResp: - properties: - category: - example: web - type: string - chall_id: - example: 3 - type: integer - name: - example: Web Challenge - type: string - points: - example: 50 - type: integer - solvedAt: - type: string - user_id: - example: 3 - type: integer - username: - example: fristonio - type: string - flag: - example: flag{@#$} - type: string - type: object - api.UserResp: - properties: - challenges: - items: - $ref: '#/definitions/api.ChallengeSolveResp' - type: array - email: - example: fristonio@gmail.com - type: string - id: - example: 5 - type: integer - rank: - example: 15 - type: integer - role: - example: author - type: string - score: - example: 750 - type: integer - status: - example: 0 - type: integer - username: - example: CTF is live now! - type: string - type: object - api.UserSolveResp: - properties: - id: - example: 5 - type: integer - solvedAt: - type: string - username: - example: fristonio - type: string - type: object - api.UsersStatisticsResp: - properties: - banned_users: - type: integer - total_registered_users: - example: 120 - type: integer - unbanned_users: - type: integer - type: object -host: beast.sdslabs.co -info: - contact: - email: contact.sdslabs.co.in - name: SDSLabs - url: https://chat.sdslabs.co - description: Beast the automatic deployment tool for backdoor - license: - name: Apache 2.0 - url: http://www.apache.org/licenses/LICENSE-2.0.html - title: Beast API - version: "1.0" -paths: - /api/admin/statistics: - get: - consumes: - - application/json - description: returns various information about the competition which are used - to control competition - parameters: - - description: Bearer - in: header - name: Authorization - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/api.CompetitionInfoResp' - "400": - description: Bad Request - schema: - $ref: '#/definitions/api.HTTPErrorResp' - summary: returns competition info - tags: - - info - /api/admin/users/:action/:id: - post: - consumes: - - application/json - description: Ban/unban a user based on his user id. This operation can only - be done by admins - parameters: - - description: Action to perform ban/unban - in: query - name: action - required: true - type: string - - description: Id of user - in: query - name: id - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/api.ChallengeStatusResp' - "400": - description: Bad Request - schema: - $ref: '#/definitions/api.HTTPPlainResp' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/api.HTTPPlainResp' - summary: Ban/Unban a user based on his id and the action provided. - tags: - - admin - /api/config/competition-info: - post: - consumes: - - application/json - description: Populates beast gobal config map by reparsing the config file $HOME/.beast/config.toml. - parameters: - - description: Competition Name - in: formData - name: name - required: true - type: string - - description: Some information about competition - in: formData - name: about - required: true - type: string - - description: Competitions Prizes for the winners - in: formData - name: prizes - type: string - - description: Competition's starting time - in: formData - name: starting_time - required: true - type: string - - description: Competition's ending time - in: formData - name: ending_time - required: true - type: string - - description: Competition's timezone - in: formData - name: timezone - required: true - type: string - - description: Competition's logo - in: formData - name: logo - type: file - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/api.HTTPPlainResp' - "400": - description: Bad Request - schema: - $ref: '#/definitions/api.HTTPPlainResp' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/api.HTTPErrorResp' - summary: Updates competition info in the beast global configuration, located - at ~/.beast/config.toml. - tags: - - config - /api/config/reload/: - patch: - consumes: - - application/json - description: Populates beast gobal config map by reparsing the config file $HOME/.beast/config.toml. - parameters: - - description: Bearer - in: header - name: Authorization - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/api.HTTPPlainResp' - "400": - description: Bad Request - schema: - $ref: '#/definitions/api.HTTPPlainResp' - summary: Reloads any changes in beast global configuration, located at ~/.beast/config.toml. - tags: - - config - /api/info/challenge/info: - get: - consumes: - - application/json - description: Returns all information about the challenges by the challenge name. - parameters: - - description: Bearer - in: header - name: Authorization - required: true - type: string - - description: Name of challenge - in: query - name: name - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/api.ChallengeInfoResp' - "400": - description: Bad Request - schema: - $ref: '#/definitions/api.HTTPErrorResp' - "404": - description: Not Found - schema: - $ref: '#/definitions/api.HTTPErrorResp' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/api.HTTPErrorResp' - summary: Returns all information about the challenges. - tags: - - info - /api/info/challenges: - get: - consumes: - - application/json - description: Returns information about all the challenges present in the database - with and without filters. - parameters: - - description: Filter parameter by which challenges are filtered - in: query - name: filter - type: string - - description: Value of filtered parameter - in: query - name: value - type: string - - description: Bearer - in: header - name: Authorization - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/api.ChallengeInfoResp' - "400": - description: Bad Request - schema: - $ref: '#/definitions/api.HTTPErrorResp' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/api.HTTPErrorResp' - summary: Returns information about all challenges with and without filters. - tags: - - info - /api/info/images/available: - get: - consumes: - - application/json - description: Returns all the available base images which can be used for challenge - creation as the base OS for challenge. - parameters: - - description: Bearer - in: header - name: Authorization - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/api.AvailableImagesResp' - summary: Gives all the base images that can be used while creating a beast challenge, - this is a constant specified in beast global config - tags: - - info - /api/info/logs: - get: - consumes: - - application/json - description: Gives container logs for a particular challenge, useful for debugging - purposes. - parameters: - - description: Bearer - in: header - name: Authorization - required: true - type: string - - description: The name of the challenge to get the logs for. - in: query - name: challenge - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/api.LogsInfoResp' - "400": - description: Bad Request - schema: - $ref: '#/definitions/api.HTTPPlainResp' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/api.HTTPPlainResp' - summary: Handles route related to logs handling of container - tags: - - info - /api/info/ports/used: - get: - consumes: - - application/json - description: Returns the ports in use by beast, which cannot be used in creating - a new challenge.. - parameters: - - description: Bearer - in: header - name: Authorization - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/api.PortsInUseResp' - summary: Returns ports in use by beast by looking in the hack git repository, - also returns min and max value of port allowed while specifying in beast challenge - config. - tags: - - info - /api/info/submissions: - get: - consumes: - - application/json - description: Handles submissions made by the user - parameters: - - description: Bearer - in: header - name: Authorization - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/api.SubmissionResp' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/api.HTTPErrorResp' - summary: Handles submissions made by the user - tags: - - info - /api/info/user: - get: - consumes: - - application/json - description: Returns user info based on userId - parameters: - - description: Bearer - in: header - name: Authorization - required: true - type: string - - description: User's id - in: formData - name: value - type: string - - description: username - in: query - name: value - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/api.UserResp' - "400": - description: Bad Request - schema: - $ref: '#/definitions/api.HTTPErrorResp' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/api.HTTPErrorResp' - summary: Returns user info - tags: - - info - /api/info/user/available: - get: - consumes: - - application/json - description: Returns all available user's info - parameters: - - description: Bearer - in: header - name: Authorization - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/api.UserResp' - "404": - description: Not Found - schema: - $ref: '#/definitions/api.HTTPErrorResp' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/api.HTTPErrorResp' - summary: Returns all user's info - tags: - - info - /api/manage/challenge/: - post: - consumes: - - application/json - description: Handles challenge management routes with actions which includes - - DEPLOY, UNDEPLOY, PURGE. - parameters: - - description: Name of the challenge to be managed, here name is the unique - identifier for challenge - in: query - name: name - required: true - type: string - - description: Action for the challenge - in: query - name: action - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/api.HTTPPlainResp' - "400": - description: Bad Request - schema: - $ref: '#/definitions/api.HTTPPlainResp' - summary: Handles challenge management actions. - tags: - - manage - /api/manage/challenge/upload: - post: - consumes: - - application/json - description: Handles the challenge management from a challenge in tar file. - Currently prepare the tar file - parameters: - - description: .tar file to be uploaded to fetch challenge info - in: formData - name: file - required: true - type: file - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/api.ChallengePreviewResp' - "400": - description: Bad Request - schema: - $ref: '#/definitions/api.HTTPErrorResp' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/api.HTTPErrorResp' - summary: Untar and fetch info from beast.toml file in challenge - tags: - - manage - /api/manage/commit/: - post: - consumes: - - application/json - description: Validates challenge configuration for deployment. - parameters: - - description: Name of the challenge to verify the deployment configuration - for. - in: query - name: challenge - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/api.HTTPErrorResp' - summary: Validates the configuration of the challenge and tells if challenge - can be deployed or not. - tags: - - manage - /api/manage/deploy/local: - post: - consumes: - - application/json - description: Handles deployment of a challenge using the absolute directory - path - parameters: - - description: Challenge Directory - in: query - name: challenge_dir - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/api.HTTPPlainResp' - "400": - description: Bad Request - schema: - $ref: '#/definitions/api.HTTPPlainResp' - "406": - description: Not Acceptable - schema: - $ref: '#/definitions/api.HTTPPlainResp' - summary: Deploy a local challenge using the path provided in the post parameter - tags: - - manage - /api/manage/multiple/:action: - post: - consumes: - - application/json - description: Handles challenge management routes for multiple the challenges - with actions which includes - DEPLOY, UNDEPLOY. - parameters: - - description: Action for the challenge - in: query - name: action - required: true - type: string - - description: Tag for a group of challenges - in: query - name: tag - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/api.HTTPPlainResp' - "400": - description: Bad Request - schema: - $ref: '#/definitions/api.HTTPPlainResp' - summary: Handles challenge management actions for multiple challenges. - tags: - - manage - /api/manage/schedule/:action: - post: - consumes: - - application/json - description: Handles scheduleing of challenge action to executed at some later - point of time - parameters: - - description: Bearer - in: header - name: Authorization - required: true - type: string - - description: Action for the underlying challenge in context - in: query - name: action - required: true - type: string - - description: The name of the challenge to schedule the action for. - in: query - name: challenge - type: string - - description: Tag corresponding to challenges in context, optional if challenge - name is provided - in: query - name: tags - type: string - - description: Timestamp at which the challenge should be scheduled should be - a unix timestamp string. - in: query - name: at - type: string - - description: Time after which the action on the selector should be executed - should be of duration format as in '1m20s' etc. - in: query - name: after - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/api.HTTPPlainResp' - "400": - description: Bad Request - schema: - $ref: '#/definitions/api.HTTPPlainResp' - summary: Schedule an action(deploy, undeploy, purge etc.) on a particular challenge - tags: - - manage - /api/manage/static/:action: - post: - consumes: - - application/json - description: Handles beast static content serving container routes. - parameters: - - description: Action to apply on the beast static content provider - in: query - name: action - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/api.HTTPPlainResp' - "400": - description: Bad Request - schema: - $ref: '#/definitions/api.HTTPPlainResp' - summary: Handles route related to beast static content serving container, takes - action as route parameter and perform that action - tags: - - manage - /api/notification/add: - post: - consumes: - - application/json - description: Adds notifications - parameters: - - description: Title of notification to be added - in: formData - name: title - required: true - type: string - - description: Description for the notification to be added - in: formData - name: desc - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/api.HTTPPlainResp' - "400": - description: Bad Request - schema: - $ref: '#/definitions/api.HTTPErrorResp' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/api.HTTPErrorResp' - summary: Adds notifications - tags: - - notification - /api/notification/available: - post: - consumes: - - application/json - description: Fetch all the notifications from database - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/api.HTTPPlainResp' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/api.HTTPErrorResp' - summary: Fetch available notifications - tags: - - notification - /api/notification/delete: - post: - consumes: - - application/json - description: Removes notifications - parameters: - - description: Title of notification - in: formData - name: id - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/api.HTTPPlainResp' - "400": - description: Bad Request - schema: - $ref: '#/definitions/api.HTTPErrorResp' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/api.HTTPErrorResp' - summary: Removes notifications - tags: - - notification - /api/notification/update: - post: - consumes: - - application/json - description: Updates any changes in the notifications - parameters: - - description: Title of notification - in: formData - name: id - required: true - type: string - - description: Title of notification - in: formData - name: title - required: true - type: string - - description: Description for the notification to be changed - in: formData - name: desc - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/api.HTTPPlainResp' - "400": - description: Bad Request - schema: - $ref: '#/definitions/api.HTTPErrorResp' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/api.HTTPErrorResp' - summary: Updates notifications - tags: - - notification - /api/remote/reset/: - post: - consumes: - - application/json - description: Resets local copy of remote git directory, it first deletes the - existing directory and then clone from the remote again. - parameters: - - description: Bearer - in: header - name: Authorization - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/api.HTTPPlainResp' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/api.HTTPPlainResp' - summary: Resets beast local copy of remote git repository. - tags: - - remote - /api/remote/sync/: - post: - consumes: - - application/json - description: Syncs beasts local challenges database with the remote git repository(hack) - the local copy of the challenge database is located at $HOME/.beast/remote/$REMOTE_NAME. - parameters: - - description: Bearer - in: header - name: Authorization - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/api.HTTPPlainResp' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/api.HTTPPlainResp' - summary: Syncs beast's local copy of remote git repository for challenges. - tags: - - remote - /api/status/all/:filter: - get: - consumes: - - application/json - description: This returns the challenges in the status provided, along with - their name and last updated time. - parameters: - - description: Status type to filter with, if none specified then all - in: query - name: filter - required: true - type: string - - description: Bearer - in: header - name: Authorization - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - items: - $ref: '#/definitions/api.ChallengeStatusResp' - type: array - "400": - description: Bad Request - schema: - $ref: '#/definitions/api.HTTPPlainResp' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/api.HTTPPlainResp' - summary: Returns challenge deployment status from the beast database for the - challenges which matches the stauts according to filter. - tags: - - status - /api/status/challenge/:name: - get: - consumes: - - application/json - description: Returns challenge deployment status from the beast database, for - those challenges which are not present a status value NA is returned. - parameters: - - description: Name of the challenge - in: query - name: name - required: true - type: string - - description: Bearer - in: header - name: Authorization - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/api.ChallengeStatusResp' - "400": - description: Bad Request - schema: - $ref: '#/definitions/api.HTTPPlainResp' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/api.HTTPPlainResp' - summary: Returns challenge deployment status from the beast database. - tags: - - status - /api/submit/challenge: - post: - consumes: - - application/json - description: Returns success or error response based on the flag submitted. - Also, the flag will not be submitted if it was previously submitted - parameters: - - description: Name of challenge - in: formData - name: chall_id - required: true - type: string - - description: Flag for the challenge - in: formData - name: flag - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/api.ChallengeStatusResp' - "400": - description: Bad Request - schema: - $ref: '#/definitions/api.HTTPPlainResp' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/api.HTTPPlainResp' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/api.HTTPPlainResp' - summary: Verifies and creates an entry in the database for successful submission - of flag for a challenge. - tags: - - Submit - /auth/login: - post: - consumes: - - application/json - description: JWT can be received by signing in - parameters: - - description: Username - in: formData - name: username - required: true - type: string - - description: Password - in: formData - name: password - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/api.HTTPAuthorizeResp' - "400": - description: Bad Request - schema: - $ref: '#/definitions/api.HTTPPlainResp' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/api.HTTPPlainResp' - "403": - description: Forbidden - schema: - $ref: '#/definitions/api.HTTPPlainResp' - summary: Handles signin and token production - tags: - - auth - /auth/register: - post: - description: Signup route for the user - parameters: - - description: User's name - in: formData - name: name - type: string - - description: Username - in: formData - name: username - required: true - type: string - - description: Password - in: formData - name: password - required: true - type: string - - description: User's email id - in: formData - name: email - required: true - type: string - - description: User's ssh-key - in: formData - name: ssh-key - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/api.HTTPPlainResp' - "400": - description: Bad Request - schema: - $ref: '#/definitions/api.HTTPPlainResp' - "406": - description: Not Acceptable - schema: - $ref: '#/definitions/api.HTTPPlainResp' - summary: Signup for the user - tags: - - auth - /auth/reset-password: - post: - description: Resets password for the user - parameters: - - description: New Password - in: formData - name: new_pass - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/api.HTTPPlainResp' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/api.HTTPPlainResp' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/api.HTTPPlainResp' - summary: Resets password for the user - tags: - - auth -securityDefinitions: - ApiKeyAuth: - in: header - name: Authorization - type: apiKey -swagger: "2.0" diff --git a/api/exec.go b/api/exec.go index 34b6de49..d0eb4470 100644 --- a/api/exec.go +++ b/api/exec.go @@ -27,20 +27,20 @@ const ( maxExecTimeout = 300 ) -type execChallengeRequest struct { +type ExecChallengeRequest struct { Command []string `json:"command"` InstanceID string `json:"instance_id,omitempty"` TimeoutSeconds int `json:"timeout_seconds,omitempty"` } -type execChallengeResponse struct { +type ExecChallengeResponse struct { Stdout string `json:"stdout"` Stderr string `json:"stderr"` ExitCode int `json:"exit_code"` Truncated bool `json:"truncated"` } -func (request *execChallengeRequest) validate() error { +func (request *ExecChallengeRequest) validate() error { if len(request.Command) == 0 || len(request.Command) > maxExecArguments { return errors.New("command must contain between 1 and 64 arguments") } @@ -79,9 +79,20 @@ func userCanExecChallenge(user database.User, challenge database.Challenge, main return challenge.AuthorID == user.ID || maintainer } +// @Summary Execute an argument-vector command in an owned challenge container +// @Tags manage +// @Accept json +// @Produce json +// @Param name path string true "Challenge name" +// @Param request body api.ExecChallengeRequest true "Bounded exec request" +// @Security ApiKeyAuth +// @Success 200 {object} api.ExecChallengeResponse +// @Failure 400 {object} api.HTTPErrorResp +// @Failure 403 {object} api.HTTPErrorResp +// @Router /api/manage/challenge/{name}/exec [post] func execChallengeHandler(c *gin.Context) { c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxExecRequestBytes) - var request execChallengeRequest + var request ExecChallengeRequest if err := c.ShouldBindJSON(&request); err != nil { c.JSON(http.StatusBadRequest, HTTPErrorResp{Error: "invalid exec request"}) return @@ -167,7 +178,7 @@ func execChallengeHandler(c *gin.Context) { } return } - c.JSON(http.StatusOK, execChallengeResponse{ + c.JSON(http.StatusOK, ExecChallengeResponse{ Stdout: result.Stdout, Stderr: result.Stderr, ExitCode: result.ExitCode, Truncated: result.Truncated, }) } diff --git a/api/exec_test.go b/api/exec_test.go index 62e22346..205c0b5d 100644 --- a/api/exec_test.go +++ b/api/exec_test.go @@ -11,7 +11,7 @@ import ( ) func TestExecChallengeRequestValidation(t *testing.T) { - tests := []execChallengeRequest{ + tests := []ExecChallengeRequest{ {}, {Command: []string{""}}, {Command: []string{"echo", string([]byte{'a', 0, 'b'})}}, @@ -24,7 +24,7 @@ func TestExecChallengeRequestValidation(t *testing.T) { } } - valid := execChallengeRequest{Command: []string{"sh", "-lc", "id"}} + valid := ExecChallengeRequest{Command: []string{"sh", "-lc", "id"}} if err := valid.validate(); err != nil { t.Fatal(err) } diff --git a/api/info.go b/api/info.go index 804de024..6d88c3bb 100644 --- a/api/info.go +++ b/api/info.go @@ -40,6 +40,15 @@ func markLeaderboardCachesStale() { leaderboardCacheMu.Unlock() } +// @Summary Read or purchase a challenge hint +// @Tags info +// @Produce json +// @Param hintID path int true "Hint ID" +// @Security ApiKeyAuth +// @Success 200 {object} api.HintResponse +// @Failure 400 {object} api.HTTPErrorResp +// @Router /api/info/hint/{hintID} [get] +// @Router /api/info/hint/{hintID} [post] func hintHandler(c *gin.Context) { hintIDStr := c.Param("hintID") @@ -476,7 +485,8 @@ func availableImagesHandler(c *gin.Context) { // @Success 200 {object} api.LogsInfoResp // @Failure 400 {object} api.HTTPPlainResp // @Failure 500 {object} api.HTTPPlainResp -// @Router /api/info/logs [get] +// @Security ApiKeyAuth +// @Router /api/manage/logs [get] func challengeLogsHandler(c *gin.Context) { chall := c.Query("challenge") if chall == "" { @@ -509,14 +519,14 @@ func challengeLogsHandler(c *gin.Context) { // @Accept json // @Produce json // @Param Authorization header string true "Bearer" -// @Param value formData string false "User's id" -// @Param value query string false "username" +// @Param user_id query int false "User ID" +// @Param username path string true "Username" // @Success 200 {object} api.UserResp // @Failure 400 {object} api.HTTPErrorResp // @Failure 500 {object} api.HTTPErrorResp -// @Router /api/info/user [get] +// @Router /api/info/user/{username} [get] func userInfoHandler(c *gin.Context) { - userId := c.PostForm("user_id") + userId := c.Query("user_id") username := c.Param("username") if userId == "" && username == "" { c.JSON(http.StatusBadRequest, HTTPErrorResp{ @@ -844,7 +854,7 @@ func submissionsHandler(c *gin.Context) { // @Success 200 {object} api.UsersStatisticsResp // @Failure 404 {object} api.HTTPErrorResp // @Failure 500 {object} api.HTTPErrorResp -// @Router /api/info/competition-info [get] +// @Router /api/admin/statistics [get] func getUsersStatisticsHandler(c *gin.Context) { users, err := database.QueryAllUsers() if err != nil { @@ -890,7 +900,7 @@ func getUsersStatisticsHandler(c *gin.Context) { // @Param Authorization header string true "Bearer" // @Success 200 {object} api.CompetitionInfoResp // @Failure 400 {object} api.HTTPErrorResp -// @Router /api/info/tags [get] +// @Router /api/info/competition-info [get] func competitionInfoHandler(c *gin.Context) { competitionInfo, err := config.GetCompetitionInfo() if err != nil { @@ -922,7 +932,7 @@ func competitionInfoHandler(c *gin.Context) { // @Param Authorization header string true "Bearer" // @Success 200 {object} api.TagInfoResp // @Failure 400 {object} api.HTTPErrorResp -// @Router /api/admin/statistics [get] +// @Router /api/info/tags [get] func tagHandler(c *gin.Context) { // Optimized: Query unique tags directly from the database tags, err := database.QueryAllUniqueTags() @@ -1302,7 +1312,7 @@ func unfreezeLeaderboardHandler(c *gin.Context) { // @Success 200 {array} api.UserSolveResp // @Failure 400 {object} api.HTTPErrorResp // @Failure 500 {object} api.HTTPErrorResp -// @Router /api/challenges/{challenge_id}/attempts [get] +// @Router /api/info/submissions/challenge/{challenge_id} [get] func getChallengeAttempts(c *gin.Context) { username, err := utils.GetUser(c.GetHeader("Authorization")) if err != nil { @@ -1501,6 +1511,12 @@ func getUserAttempts(c *gin.Context) { c.JSON(http.StatusOK, resp) } +// @Summary Return leaderboard score history +// @Tags info +// @Produce json +// @Security ApiKeyAuth +// @Success 200 {array} database.UserLeaderboardResp +// @Router /api/info/leaderboard-graph [get] func getLeaderboardGraphHandler(c *gin.Context) { leaderboardCacheMu.Lock() defer leaderboardCacheMu.Unlock() diff --git a/api/instance.go b/api/instance.go index e2adc9ab..287d2d7c 100644 --- a/api/instance.go +++ b/api/instance.go @@ -59,6 +59,14 @@ func instanceToAdminResponse(instance *cache.Instance) AdminInstanceResponse { } } +// @Summary Spawn a per-user challenge instance +// @Tags instances +// @Produce json +// @Param challenge_name path string true "Challenge name" +// @Security ApiKeyAuth +// @Success 200 {object} api.InstanceResponse +// @Failure 400 {object} api.HTTPErrorResp +// @Router /api/instances/{challenge_name}/spawn [post] func spawnInstanceHandler(ctx *gin.Context) { challengeName := ctx.Param("challenge_name") if challengeName == "" { @@ -103,6 +111,14 @@ func spawnInstanceHandler(ctx *gin.Context) { ctx.JSON(http.StatusOK, instanceToResponse(instance)) } +// @Summary Get the current user's challenge instance +// @Tags instances +// @Produce json +// @Param challenge_name path string true "Challenge name" +// @Security ApiKeyAuth +// @Success 200 {object} api.InstanceResponse +// @Failure 404 {object} api.HTTPErrorResp +// @Router /api/instances/{challenge_name} [get] func getUserInstanceHandler(ctx *gin.Context) { challengeName := ctx.Param("challenge_name") if challengeName == "" { @@ -141,6 +157,12 @@ func getUserInstanceHandler(ctx *gin.Context) { ctx.JSON(http.StatusOK, instanceToResponse(instance)) } +// @Summary List the current user's instances +// @Tags instances +// @Produce json +// @Security ApiKeyAuth +// @Success 200 {array} api.InstanceResponse +// @Router /api/instances [get] func getUserInstancesHandler(ctx *gin.Context) { username, err := coreUtils.GetUser(ctx.GetHeader("Authorization")) if err != nil { @@ -180,6 +202,15 @@ func getUserInstancesHandler(ctx *gin.Context) { ctx.JSON(http.StatusOK, response) } +// @Summary Extend the current user's challenge instance +// @Tags instances +// @Produce json +// @Param challenge_name path string true "Challenge name" +// @Param seconds formData int false "Requested extension in seconds" +// @Security ApiKeyAuth +// @Success 200 {object} api.InstanceResponse +// @Failure 400 {object} api.HTTPErrorResp +// @Router /api/instances/{challenge_name}/extend [post] func extendInstanceHandler(ctx *gin.Context) { challengeName := ctx.Param("challenge_name") if challengeName == "" { @@ -248,6 +279,13 @@ func extendInstanceHandler(ctx *gin.Context) { ctx.JSON(http.StatusOK, instanceToResponse(instance)) } +// @Summary Delete the current user's challenge instance +// @Tags instances +// @Produce json +// @Param challenge_name path string true "Challenge name" +// @Security ApiKeyAuth +// @Success 200 {object} api.HTTPPlainResp +// @Router /api/instances/{challenge_name} [delete] func killUserInstanceHandler(ctx *gin.Context) { challengeName := ctx.Param("challenge_name") if challengeName == "" { @@ -288,6 +326,12 @@ func killUserInstanceHandler(ctx *gin.Context) { }) } +// @Summary List all active instances +// @Tags admin +// @Produce json +// @Security ApiKeyAuth +// @Success 200 {array} api.AdminInstanceResponse +// @Router /api/admin/instances [get] func adminGetAllInstancesHandler(ctx *gin.Context) { instances, err := manager.GetAllInstances() if err != nil { @@ -309,6 +353,13 @@ func adminGetAllInstancesHandler(ctx *gin.Context) { ctx.JSON(http.StatusOK, response) } +// @Summary Get an instance by ID +// @Tags admin +// @Produce json +// @Param instance_id path string true "Instance ID" +// @Security ApiKeyAuth +// @Success 200 {object} api.AdminInstanceResponse +// @Router /api/admin/instances/{instance_id} [get] func adminGetInstanceHandler(ctx *gin.Context) { instanceID := ctx.Param("instance_id") if instanceID == "" { @@ -329,6 +380,13 @@ func adminGetInstanceHandler(ctx *gin.Context) { ctx.JSON(http.StatusOK, instanceToAdminResponse(instance)) } +// @Summary Delete an instance by ID +// @Tags admin +// @Produce json +// @Param instance_id path string true "Instance ID" +// @Security ApiKeyAuth +// @Success 200 {object} api.HTTPPlainResp +// @Router /api/admin/instances/{instance_id} [delete] func adminKillInstanceHandler(ctx *gin.Context) { instanceID := ctx.Param("instance_id") if instanceID == "" { @@ -351,6 +409,13 @@ func adminKillInstanceHandler(ctx *gin.Context) { }) } +// @Summary Delete all instances owned by a user +// @Tags admin +// @Produce json +// @Param user_id path string true "User ID" +// @Security ApiKeyAuth +// @Success 200 {object} api.HTTPPlainResp +// @Router /api/admin/instances/user/{user_id} [delete] func adminKillUserInstancesHandler(ctx *gin.Context) { userID := ctx.Param("user_id") if userID == "" { @@ -381,6 +446,13 @@ func adminKillUserInstancesHandler(ctx *gin.Context) { }) } +// @Summary Delete all instances for a challenge +// @Tags admin +// @Produce json +// @Param challenge_name path string true "Challenge name" +// @Security ApiKeyAuth +// @Success 200 {object} api.HTTPPlainResp +// @Router /api/admin/instances/challenge/{challenge_name} [delete] func adminKillChallengeInstancesHandler(ctx *gin.Context) { challengeName := ctx.Param("challenge_name") if challengeName == "" { diff --git a/api/main.go b/api/main.go index e74ddf0d..8b5ee614 100644 --- a/api/main.go +++ b/api/main.go @@ -39,18 +39,18 @@ func runBeastApiBootsteps(defaultauthorpassword string) error { } // @title Beast API -// @version 1.0 -// @description Beast the automatic deployment tool for playCTF +// @version 0.2 +// @description Authenticated API for Beast CTF challenge deployment and competition services. // @contact.name SDSLabs // @contact.url https://chat.sdslabs.co -// @contact.email contact.sdslabs.co.in +// @contact.email contact@sdslabs.co.in // @license.name Apache 2.0 -// @license.url http://www.apache.org/licenses/LICENSE-2.0.html +// @license.url https://www.apache.org/licenses/LICENSE-2.0.html -// @host playCTF.sdslabs.co // @BasePath / +// @schemes https // @securityDefinitions.apikey ApiKeyAuth // @in header diff --git a/api/manage.go b/api/manage.go index 7fbc7211..14c483a1 100644 --- a/api/manage.go +++ b/api/manage.go @@ -40,7 +40,7 @@ var challengeUploadMu sync.Mutex // @Param tag query string false "Tag for a group of challenges" // @Success 200 {object} api.HTTPPlainResp // @Failure 400 {object} api.HTTPPlainResp -// @Router /api/manage/multiple/:action [post] +// @Router /api/manage/multiple/{action} [post] func manageMultipleChallengeHandlerTagBased(c *gin.Context) { // If no tags are provided we by default we apply the action to all // the challenges. @@ -213,7 +213,7 @@ func manageMultipleChallengeHandlerNameBased(c *gin.Context) { // @Success 200 {object} api.HTTPPlainResp // @Failure 400 {object} api.HTTPPlainResp // @Failure 406 {object} api.HTTPPlainResp -// @Router /api/manage/deploy/local [post] +// @Router /api/manage/deploy/local/ [post] func deployLocalChallengeHandler(c *gin.Context) { action := core.MANAGE_ACTION_DEPLOY challDir := c.PostForm("challenge_dir") @@ -273,7 +273,7 @@ func deployLocalChallengeHandler(c *gin.Context) { // @Param action query string true "Action to apply on the beast static content provider" // @Success 200 {object} api.HTTPPlainResp // @Failure 400 {object} api.HTTPPlainResp -// @Router /api/manage/static/:action [post] +// @Router /api/manage/static/{action} [post] func beastStaticContentHandler(c *gin.Context) { action := c.Param("action") identifier := core.BEAST_STATIC_CONTAINER_NAME @@ -391,7 +391,7 @@ func verifyHandler(c *gin.Context) { // @Param after query string false "Time after which the action on the selector should be executed should be of duration format as in '1m20s' etc." // @Success 200 {object} api.HTTPPlainResp // @Failure 400 {object} api.HTTPPlainResp -// @Router /api/manage/schedule/:action [post] +// @Router /api/manage/schedule/{action} [post] func manageScheduledAction(c *gin.Context) { action := c.Param("action") challenge := c.PostForm("challenge") @@ -657,6 +657,15 @@ func persistUploadedChallenge(source, challengeName string) error { return os.Rename(stagedChallenge, destination) } +// @Summary Validate a configured challenge flag as its manager +// @Tags manage +// @Produce json +// @Param challenge_name formData string true "Challenge name" +// @Param flag formData string true "Flag to validate" +// @Security ApiKeyAuth +// @Success 200 {object} api.HTTPPlainResp +// @Failure 403 {object} api.HTTPErrorResp +// @Router /api/manage/challenge/validateflag [post] func validateFlagHandler(c *gin.Context) { flag := c.PostForm("flag") challenge_name := c.PostForm("challenge_name") diff --git a/api/notification.go b/api/notification.go index a550ca16..cba1cd8d 100644 --- a/api/notification.go +++ b/api/notification.go @@ -64,7 +64,7 @@ func addNotification(c *gin.Context) { // @Success 200 {object} api.HTTPPlainResp // @Failure 400 {object} api.HTTPErrorResp // @Failure 500 {object} api.HTTPErrorResp -// @Router /api/notification/delete [post] +// @Router /api/notification/delete [delete] func removeNotification(c *gin.Context) { id := c.PostForm("id") @@ -117,7 +117,7 @@ func removeNotification(c *gin.Context) { // @Success 200 {object} api.HTTPPlainResp // @Failure 400 {object} api.HTTPErrorResp // @Failure 500 {object} api.HTTPErrorResp -// @Router /api/notification/update [post] +// @Router /api/notification/update [put] func updateNotifications(c *gin.Context) { id := c.PostForm("id") changedtitle := c.PostForm("title") @@ -170,7 +170,7 @@ func updateNotifications(c *gin.Context) { // @Produce json // @Success 200 {object} api.HTTPPlainResp // @Failure 500 {object} api.HTTPErrorResp -// @Router /api/notification/available [post] +// @Router /api/notification/available [get] func availableNotificationHandler(c *gin.Context) { notifications, err := database.QueryAllNotification() if err != nil { @@ -204,6 +204,12 @@ func availableNotificationHandler(c *gin.Context) { return } +// @Summary Stream server-sent notifications +// @Tags notification +// @Produce text/event-stream +// @Security ApiKeyAuth +// @Success 200 {string} string "SSE stream" +// @Router /api/notification/stream [get] func streamNotification(c *gin.Context) { c.Writer.Header().Set("Content-Type", "text/event-stream") c.Writer.Header().Set("Cache-Control", "no-cache") diff --git a/api/otp.go b/api/otp.go index ddd8963b..7821af2b 100644 --- a/api/otp.go +++ b/api/otp.go @@ -294,10 +294,27 @@ func verifyRequestedOTP(c *gin.Context, purpose string) (string, bool) { return email, true } +// @Summary Send an email verification OTP +// @Tags auth +// @Accept multipart/form-data +// @Produce json +// @Param email formData string true "Email address" +// @Success 200 {object} api.HTTPPlainResp +// @Failure 400 {object} api.HTTPErrorResp +// @Router /auth/send-otp [post] func sendOTPHandler(c *gin.Context) { issueOTP(c, otpPurposeRegistration, false) } +// @Summary Verify an email OTP +// @Tags auth +// @Accept multipart/form-data +// @Produce json +// @Param email formData string true "Email address" +// @Param otp formData string true "One-time code" +// @Success 200 {object} api.HTTPPlainResp +// @Failure 400 {object} api.HTTPErrorResp +// @Router /auth/verify-otp [post] func verifyOTPHandler(c *gin.Context) { if _, ok := verifyRequestedOTP(c, otpPurposeRegistration); !ok { return @@ -305,10 +322,27 @@ func verifyOTPHandler(c *gin.Context) { c.JSON(http.StatusOK, HTTPPlainResp{Message: "OTP verified successfully"}) } +// @Summary Send a password-reset OTP +// @Tags auth +// @Accept multipart/form-data +// @Produce json +// @Param email formData string true "Registered email address" +// @Success 200 {object} api.HTTPPlainResp +// @Failure 400 {object} api.HTTPErrorResp +// @Router /auth/send-otp-forget [post] func sendOTPForForgetHandler(c *gin.Context) { issueOTP(c, otpPurposePasswordReset, true) } +// @Summary Verify a password-reset OTP +// @Tags auth +// @Accept multipart/form-data +// @Produce json +// @Param email formData string true "Registered email address" +// @Param otp formData string true "One-time code" +// @Success 200 {object} api.HTTPAuthorizeResp +// @Failure 400 {object} api.HTTPErrorResp +// @Router /auth/verify-otp-forget [post] func verifyOTPForForgetHandler(c *gin.Context) { email, ok := verifyRequestedOTP(c, otpPurposePasswordReset) if !ok { diff --git a/api/remote.go b/api/remote.go index 8066e645..f9995797 100644 --- a/api/remote.go +++ b/api/remote.go @@ -18,7 +18,7 @@ import ( // @Param Authorization header string true "Bearer" // @Success 200 {object} api.HTTPPlainResp // @Failure 500 {object} api.HTTPPlainResp -// @Router /api/remote/sync/ [post] +// @Router /api/remote/sync [post] func syncBeastGitRemote(c *gin.Context) { err := manager.SyncBeastRemote("") if err != nil { @@ -43,7 +43,7 @@ func syncBeastGitRemote(c *gin.Context) { // @Param Authorization header string true "Bearer" // @Success 200 {object} api.HTTPPlainResp // @Failure 500 {object} api.HTTPPlainResp -// @Router /api/remote/reset/ [post] +// @Router /api/remote/reset [post] func resetBeastGitRemote(c *gin.Context) { err := manager.ResetBeastRemote("") if err != nil { diff --git a/api/response.go b/api/response.go index 04d0dce8..111f7d81 100644 --- a/api/response.go +++ b/api/response.go @@ -24,13 +24,13 @@ type HTTPAuthorizeResp struct { type AvailableImagesResp struct { Message string `json:"message" example:"Available Base images."` - Images []string `json:"images" example:"['ubuntu16.04', 'ubuntu18.04']"` + Images []string `json:"images" example:"ubuntu:24.04,debian:bookworm"` } type PortsInUseResp struct { MinPortValue uint32 `json:"port_min_value" example:"10000"` MaxPortValue uint32 `json:"port_max_value" example:"20000"` - PortsInUse []uint32 `json:"ports_in_use" example:"[100001, 100003, 10010]"` + PortsInUse []uint32 `json:"ports_in_use" example:"10001,10003,10010"` } type ChallengeStatusResp struct { @@ -82,7 +82,7 @@ type ChallengeSolveResp struct { Id uint `json:"id" example:"4"` Name string `json:"name" example:"Web Challenge"` Category string `json:"category" example:"bare"` - Tags []string `json:"tags" example:"['pwn','misc']"` + Tags []string `json:"tags" example:"pwn,misc"` SolvedAt time.Time `json:"solvedAt"` Points uint `json:"points" example:"50"` } @@ -108,14 +108,14 @@ type HintResponse struct { type ChallengeMetadata struct { ChallId uint `json:"id" example:"0"` Name string `json:"name" example:"Web Challenge"` - Tags []string `json:"tags" example:"['pwn','misc']"` + Tags []string `json:"tags" example:"pwn,misc"` Points uint `json:"points" example:"50"` Difficulty string `json:"difficulty" example:"easy"` SolvesNumber uint16 `json:"solvesNumber" example:"100"` SolveStatus bool `json:"solveStatus" example:"True"` CreatedAt time.Time `json:"createdAt"` DeployedStatus string `json:"deployedStatus" example:"deployed"` - PreRequisite []string `json:"preRequisite" example:"['chall1', chall2]"` + PreRequisite []string `json:"preRequisite" example:"chall1,chall2"` Instanced bool `json:"instanced" example:"false"` InstanceExpiration int64 `json:"instanceExpiration" example:"300"` } @@ -126,8 +126,8 @@ type Challenge struct { Description string `json:"description" example:"A simple web challenge"` Hints []HintInfo `json:"hints"` Category string `json:"category" example:"web"` - Assets []string `json:"assets" example:"['image1.png', 'zippy.zip']"` - AdditionalLinks []string `json:"additionalLinks" example:"['http://link1.abc:8080','http://link2.abc:8081']"` + Assets []string `json:"assets" example:"image1.png,zippy.zip"` + AdditionalLinks []string `json:"additionalLinks" example:"https://link1.example,https://link2.example"` PreviousTries int `json:"previousTries" example:"3"` MaxAttemptLimit int `json:"maxAttemptLimit" example:"5"` DeployedLink string `json:"deployedLink" example:"beast.sdslabs.co or ip:port"` @@ -143,12 +143,12 @@ type AdminChallenge struct { type ChallengePreviewResp struct { Name string `json:"name" example:"Web Challenge"` Category string `json:"category" example:"web"` - Tags []string `json:"tags" example:"['pwn','misc']"` - Assets []string `json:"assets" example:"['image1.png', 'zippy.zip']"` - AdditionalLinks []string `json:"additionalLinks" example:"['http://link1.abc:8080','http://link2.abc:8081']"` + Tags []string `json:"tags" example:"pwn,misc"` + Assets []string `json:"assets" example:"image1.png,zippy.zip"` + AdditionalLinks []string `json:"additionalLinks" example:"https://link1.example,https://link2.example"` MaxAttemptLimit int `json:"maxAttemptLimit" example:"5"` - PreReqs []string `json:"preRequisite" example:"['web-php','simple']"` - Ports []uint32 `json:"ports" example:"[3001, 3002]"` + PreReqs []string `json:"preRequisite" example:"web-php,simple"` + Ports []uint32 `json:"ports" example:"3001,3002"` Desc string `json:"description" example:"A simple web challenge"` Points uint `json:"points" example:"50"` DeployedLink string `json:"deployedLink" example:"beast.sdslabs.co"` diff --git a/api/router.go b/api/router.go index b13c2c35..9cda0b5d 100644 --- a/api/router.go +++ b/api/router.go @@ -107,12 +107,12 @@ func initGinRouter() *gin.Engine { { infoGroup.GET("/challenge/:name", challengeInfoHandler) infoGroup.GET("/challenges", challengesMetadataHandler) - // infoGroup.GET("/images/available", availableImagesHandler) + infoGroup.GET("/images/available", availableImagesHandler) // infoGroup.GET("/ports/used", usedPortsInfoHandler) infoGroup.GET("/user/:username", userInfoHandler) infoGroup.GET("/users", getAllUsersInfoHandler) infoGroup.GET("/leaderboard", getLeaderboardHandler) - infoGroup.GET("leaderboard-graph", getLeaderboardGraphHandler) + infoGroup.GET("/leaderboard-graph", getLeaderboardGraphHandler) infoGroup.GET("/usercount", getUserCountHandler) infoGroup.GET("/submissions/challenge/:challenge_id", getChallengeAttempts) infoGroup.GET("/submissions/user/:user_id", getUserAttempts) diff --git a/api/status.go b/api/status.go index 54ae4943..4d181ce5 100644 --- a/api/status.go +++ b/api/status.go @@ -22,7 +22,7 @@ import ( // @Success 200 {object} api.ChallengeStatusResp // @Failure 500 {object} api.HTTPPlainResp // @Failure 400 {object} api.HTTPPlainResp -// @Router /api/status/challenge/:name [get] +// @Router /api/status/challenge/{name} [get] func challengeStatusHandler(c *gin.Context) { name := c.Param("name") if name == "" { @@ -67,7 +67,8 @@ func challengeStatusHandler(c *gin.Context) { // @Failure 500 {object} api.HTTPPlainResp // @Failure 400 {object} api.HTTPPlainResp // @Success 200 {array} api.ChallengeStatusResp -// @Router /api/status/all/:filter [get] +// @Router /api/status/all/{filter} [get] +// @Router /api/status/all [get] func statusHandler(c *gin.Context) { filter := c.Param("filter") diff --git a/scripts/tools/swagger-docs.py b/scripts/tools/swagger-docs.py index 9ca9d33b..e2af604c 100755 --- a/scripts/tools/swagger-docs.py +++ b/scripts/tools/swagger-docs.py @@ -1,4 +1,4 @@ -#!/usr/bin/python +#!/usr/bin/env python3 TEMPLATE = """ @@ -86,7 +86,7 @@ """ -INPUT_PATH = "api/docs/swagger/swagger.json" +INPUT_PATH = "api/docs/swagger.json" OUTPUT_PATH = "site/apidocs.html" with open(INPUT_PATH, "r") as file: From da56b6fe37493cbd8da8d9cb9b908b26bf8dd941 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 16:32:16 +0530 Subject: [PATCH 148/149] fix(audit): retain repeated management actions Signed-off-by: vibhatsu --- core/database/relations_test.go | 20 ++++++++++++++++++++ core/database/submission_test.go | 2 +- core/database/transactions.go | 17 +++++++---------- core/manager/utils.go | 6 +----- 4 files changed, 29 insertions(+), 16 deletions(-) diff --git a/core/database/relations_test.go b/core/database/relations_test.go index 76234155..0da1e2a2 100644 --- a/core/database/relations_test.go +++ b/core/database/relations_test.go @@ -136,3 +136,23 @@ func TestChallengeIdentifiersAllowEmptyAndProtectActiveValues(t *testing.T) { t.Fatalf("deleted challenge identifiers should be reusable: %v", err) } } + +func TestSaveTransactionPreservesRepeatedActions(t *testing.T) { + cleanup := setupSubmissionTestDB(t) + defer cleanup() + + user := createSubmissionTestUser(t, "audit-user") + challenge := createSubmissionTestChallenge(t, "audit-challenge", -1, false) + for range 2 { + if err := SaveTransaction(&Transaction{Action: "deploy", UserID: user.ID, ChallengeID: challenge.ID}); err != nil { + t.Fatal(err) + } + } + var count int64 + if err := Db.Model(&Transaction{}).Where("user_id = ? AND challenge_id = ? AND action = ?", user.ID, challenge.ID, "deploy").Count(&count).Error; err != nil { + t.Fatal(err) + } + if count != 2 { + t.Fatalf("transaction count = %d, want 2", count) + } +} diff --git a/core/database/submission_test.go b/core/database/submission_test.go index f39c5ec9..6e74efbf 100644 --- a/core/database/submission_test.go +++ b/core/database/submission_test.go @@ -52,7 +52,7 @@ func setupSubmissionTestDB(t *testing.T) func() { Db = testDB DBMux = &sync.RWMutex{} - if err := Db.AutoMigrate(&Challenge{}, &Port{}, &Tag{}, &User{}, &UserChallenges{}, &ChallengeMaintainer{}, &DynamicFlag{}, &DynamicFlagClaim{}, &DynamicScoreDirty{}); err != nil { + if err := Db.AutoMigrate(&Challenge{}, &Transaction{}, &Port{}, &Tag{}, &User{}, &UserChallenges{}, &ChallengeMaintainer{}, &DynamicFlag{}, &DynamicFlagClaim{}, &DynamicScoreDirty{}); err != nil { t.Fatalf("auto migrate: %v", err) } if err := MigrateSubmissionGuards(); err != nil { diff --git a/core/database/transactions.go b/core/database/transactions.go index f1602335..6ae45337 100644 --- a/core/database/transactions.go +++ b/core/database/transactions.go @@ -1,6 +1,7 @@ package database import ( + "errors" "fmt" "github.com/jinzhu/gorm" @@ -16,18 +17,14 @@ type Transaction struct { } func SaveTransaction(transaction *Transaction) error { + if transaction == nil || transaction.UserID == 0 || transaction.ChallengeID == 0 || transaction.Action == "" { + return errors.New("complete transaction audit record is required") + } DBMux.Lock() defer DBMux.Unlock() - tx := Db.Begin() - - if tx.Error != nil { - return fmt.Errorf("Error while saving transaction: %v", tx.Error) - } - - if err := tx.FirstOrCreate(transaction, *transaction).Error; err != nil { - tx.Rollback() - return err + if err := Db.Create(transaction).Error; err != nil { + return fmt.Errorf("save transaction audit record: %w", err) } - return tx.Commit().Error + return nil } diff --git a/core/manager/utils.go b/core/manager/utils.go index dc261045..199f5dc5 100644 --- a/core/manager/utils.go +++ b/core/manager/utils.go @@ -676,13 +676,9 @@ func LogTransaction(identifier string, action string, authorization string) erro return fmt.Errorf("error while querying challenge: %s", identifier) } - // We are trying to get the username for the request from JWT claims here - // Since upto this point the request is already authorized, we use a default - // username if any error occurs while getting the username. userName, err := coreUtils.GetUser(authorization) if err != nil { - log.Warnf("Error while getting user from authorization header, using default user(since already authorized)") - userName = core.DEFAULT_USER_NAME + return fmt.Errorf("resolve transaction user: %w", err) } user, err := database.QueryFirstUserEntry("username", userName) From 4025d4a73054591e32ea45108d3e4d62f00e3649 Mon Sep 17 00:00:00 2001 From: vibhatsu Date: Sun, 19 Jul 2026 16:34:46 +0530 Subject: [PATCH 149/149] docs: replace obsolete operator guidance Signed-off-by: vibhatsu --- README.md | 229 +++++++--------------- _examples/README.md | 32 ++-- _examples/compose-type/README.md | 17 +- _examples/example.config.toml | 17 +- _examples/instanced-compose/README.md | 92 ++------- _examples/instanced-service/README.md | 130 ++----------- docs/APIAuth.md | 73 +++---- docs/Architecture.md | 59 +++--- docs/ChallConfig.md | 221 +++++++++------------- docs/ChallTypes.md | 67 ++----- docs/Deployment.md | 30 ++- docs/Documentation.md | 17 +- docs/Features.md | 83 ++------ docs/GettingStarted.md | 159 +--------------- docs/README.md | 50 ++--- docs/SampleChallenges.md | 190 ++----------------- docs/Setup.md | 241 ++++++------------------ docs/Usage.md | 71 +++---- docs/cmdref/beast.md | 18 +- docs/cmdref/beast_backup-cache.md | 27 +++ docs/cmdref/beast_backup-database.md | 27 +++ docs/cmdref/beast_chall-details.md | 29 +++ docs/cmdref/beast_challenge.md | 4 +- docs/cmdref/beast_cmdref.md | 5 +- docs/cmdref/beast_config.md | 27 +++ docs/cmdref/beast_create-admin.md | 30 +++ docs/cmdref/beast_create-author.md | 15 +- docs/cmdref/beast_disable-author-ssh.md | 30 --- docs/cmdref/beast_getauth.md | 11 +- docs/cmdref/beast_health-probe.md | 3 - docs/cmdref/beast_init.md | 5 +- docs/cmdref/beast_logs.md | 3 - docs/cmdref/beast_new.md | 27 +++ docs/cmdref/beast_reset-cache.md | 28 +++ docs/cmdref/beast_reset-database.md | 28 +++ docs/cmdref/beast_restore-cache.md | 29 +++ docs/cmdref/beast_restore-database.md | 29 +++ docs/cmdref/beast_run.md | 14 +- docs/cmdref/beast_verify.md | 8 +- docs/cmdref/beast_version.md | 3 - mkdocs.yml | 17 +- 41 files changed, 790 insertions(+), 1405 deletions(-) create mode 100644 docs/cmdref/beast_backup-cache.md create mode 100644 docs/cmdref/beast_backup-database.md create mode 100644 docs/cmdref/beast_chall-details.md create mode 100644 docs/cmdref/beast_config.md create mode 100644 docs/cmdref/beast_create-admin.md delete mode 100644 docs/cmdref/beast_disable-author-ssh.md create mode 100644 docs/cmdref/beast_new.md create mode 100644 docs/cmdref/beast_reset-cache.md create mode 100644 docs/cmdref/beast_reset-database.md create mode 100644 docs/cmdref/beast_restore-cache.md create mode 100644 docs/cmdref/beast_restore-database.md diff --git a/README.md b/README.md index f1182bd2..1134b122 100644 --- a/README.md +++ b/README.md @@ -1,204 +1,121 @@ -

- Beast Logo -

+# Beast -

Jeopardy-style CTF challenge deployment and management tool.

+Beast is a Linux service for building, deploying, and operating jeopardy-style CTF challenges. It exposes an HTTPS API and CLI, stores durable state in PostgreSQL, uses Redis for coordination and instance expiry, and deploys challenges through local or remote Docker daemons. -

- - Netlify Status - - - Build Status - - - Apache License - -

+## Security model -## Contents +Beast executes organizer-supplied challenge build contexts and controls Docker. Docker socket access and membership in the Docker group are effectively root-equivalent. Run Beast on a dedicated host or VM, use a dedicated unprivileged account, restrict management API access, and treat challenge authors as trusted build-code contributors. Containers reduce risk but are not a security boundary against a hostile kernel exploit. -- [Overview](#overview) -- [Features](#features) -- [Supported Challenge](#supported-challenges) -- [Download](#download) -- [Tech Stack](#tech-stack) -- [Development](#development) -- [Contributing](#contributing) -- [Contact](#contact) +The controller requires TLS. Non-loopback PostgreSQL connections require `sslmode = "verify-full"` and a CA file; non-loopback Redis connections require TLS. SSH workers verify `known_hosts`, and private keys/configuration files must be regular files with mode `0600`. -## Overview +## Requirements -Beast is a service that runs on your host(maybe a bare metal server or a cloud instance) and helps manage deployment, lifecycle, and health check of CTF challenges. It can also be used to host Jeopardy-style CTF competition. +- Linux +- Go 1.23 or newer +- Docker Engine with a reachable daemon +- Git and Make +- PostgreSQL +- Redis with ACL support -Visit [beast.sdslabs.co](https://beast.sdslabs.co/) for the more details and documentation +Use trusted operating-system packages. The setup scripts do not install system packages or pipe remote scripts into a shell. -If you're looking for the source code of playCTF, the frontend powered by Beast, visit https://github.com/sdslabs/playCTF. - -## Features - -- Git based source of truth. -- Container based isolation -- Easy configuration -- SSH support for challenge instances -- Command line interface to perform actions and host competitions -- REST API interface for the entire ecosystem -- An optional automated health check service to periodically check the status of challenges and report if there is some sort of problem with one. -- Single source of truth for all the static content related to all the challenges making it easy to debug, monitor and manage - static content through a single interface. -- Support for various notification channels like slack, discord. -- Everything embedded to a single go binary which can be easily used anywhere. - -For more details on the features, refer to [Features](./docs/Features.md) - -## Supported Challenges - -As of now beast support the following type of challenges: - -- Service - A service hosted on beast container instance -- Web - Web based challenges for various languages including PHP, Python, Node.js etc. -- Static - Challenges with static files, this may include forensics challenges. -- Bare - Highly customisable challenges. -- Docker - Challenges which are provided with their own docker file. - -## Download - -Assuming you have the [docker](https://www.docker.com/) installed, head over to Beast's [releases](https://github.com/sdslabs/beast/releases) page and grab the latest binary and `setup.sh` script. - -Run the `setup.sh` script once. It will setup the required folders and configuration files for you. - -Run the downloaded binary with +## Install and initialize ```bash -$ ./beast run -v +git clone https://github.com/sdslabs/beastv4.git +cd beastv4 +./scripts/installenv.sh +make build +beast init ``` -## Tech Stack - -Beast is written completely in Golang and comes with a clean REST API interface to trigger actions or interact with underlying functionalities. -The REST API server is implemented using `gin` go library and uses JWT as an authentication mechanism. Being written in go, Beast is compiled into -a single binary which can run on any linux distribution. - -Beast uses Docker as a container runtimes to run challenges in a sandboxed environment. Note that container does not provide a very strong isolation, but our host is safe as long as there is no 0-day in linux kernel itself. Even though container provide a security layer for the challenges, we follow some practices to harden those security measures. - -We use Swagger for automatic generation of API documentation and you can find the docs at `/api/docs/index.html` from beast server root. - -To save the state of the deployments and challenges beast uses SQLite as a database, all the information ranging from challenge deployment state to allocated ports and author information is stored in this database. This database is created automatically in the root of your beast configuration directory. - -## Development +`beast init` creates private state under `$HOME/.beast`, generates or validates the TLS certificate and configuration, provisions the configured PostgreSQL database and Redis ACL user, and can create the first administrator. It prompts before privileged datastore operations. -Beast go version is under development; follow the below instructions to get started. +For a non-interactive filesystem/bootstrap starting point, run `./setup.sh`, review the generated `$HOME/.beast/config.toml`, then run `beast init`. The setup script generates unique JWT, PostgreSQL, and Redis secrets but does not install or start those services. -- Make sure you have docker up and running. -- Install go [1.18.X](https://golang.org/dl/) or above -- Make sure that `GO111MODULES` environment variable should be set to `on`, or do `export GO111MODULES=on` -- Clone the repository. -- Jump to `$GOPATH/src/github.com/sdslabs/beast/` and start hacking. +Start the controller: ```bash -$ go version -go version go1.18 linux/amd64 - -$ export GO111MODULES=on - -$ git clone git@github.com:sdslabs/beast.git +beast run --health-probe +``` -$ cd beast && make help -BEAST: An automated challenge deployment tool for backdoor +The default endpoint is `https://localhost:5005`. For a locally generated certificate, pass its CA/certificate explicitly to clients. Do not disable TLS verification. -* build: Build Beast and copy binary to PATH set for go build binaries. -* dev: Run development environment with hot-reloading enabled -* check_format: Check for formatting errors using gofmt -* format: format the go files using go_fmt in the project directory. -* requirements: Build beast extra artifacts requirements -* test: Run tests for beast -* tools: Set up required tools for Beast which includes - docker-enter, importenv +```bash +beast getauth --host https://localhost:5005 \ + --ca-file "$HOME/.beast/secrets/tls.crt" \ + --username ``` -**All the dependencies are already vendored with the project, so no need to install any dependencies**. The project uses go modules from go 1.18.X of dependency management. Make sure you vendor any library used using `go mod vendor` +## Configuration -### Building +The complete annotated example is [`_examples/example.config.toml`](_examples/example.config.toml). Important rules: -To build Beast from Source use the Makefile provided. +- `$HOME/.beast/config.toml` must be a non-symlink regular file with mode `0600`. +- `jwt_secret` must contain at least 32 bytes. +- TLS certificate/key paths are mandatory; the private key must be mode `0600`. +- Active remote workers need a mode-`0600` SSH key and a populated `known_hosts` file. +- Resource defaults are hard ceilings for per-challenge overrides. +- CORS origins must be explicit HTTPS origins (loopback HTTP is accepted for development only). -- `make build` +## Challenge workflow -This will build Beast and place the binary in `$GOPATH/bin/` and copy the necessery tools to the desired place. To build this in production make sure you also have built the static-content docker image in `/extras/static-content` +Create a strict, parseable static-challenge scaffold in an empty directory: -To run the API server for Beast, use the command `beast run -v` - -### Hot reloading support +```bash +mkdir my-challenge && cd my-challenge +beast new +``` -- run the following command to install `air` (hot reload support) +Edit `beast.toml`, place downloadable files in `public/`, and validate before deployment: ```bash -curl -sSfL https://raw.githubusercontent.com/cosmtrek/air/master/install.sh | sh -s -- -b $(go env GOPATH)/bin +beast verify --local-directory "$PWD" ``` -- modify `full_bin` in `.air.toml` for changing the arguments to run beast, default being `beast run -nv` -- run `make dev` to start `beast` in development mode +Controller-local path deployment (`beast challenge deploy --local-directory …` and its API equivalent) is administrator-only. Authors can upload a bounded ZIP whose `author`/`maintainer` email matches their account, or manage synchronized challenges they own. -### Testing +Challenge names, referenced files, Compose build contexts, setup scripts, assets, and environment-value files are validated and must remain inside the challenge directory. Compose files accept a constrained schema and only port-variable interpolation; privileged, host-network, host-PID/IPC, device, socket, and unsafe mount controls are rejected. -To test use the sample challenges in the `_examples` directory. Use the challenge simple and try to deploy it using -Beast. Follow the below instructions. +See the [challenge configuration guide](docs/ChallConfig.md) and [examples](_examples/README.md). -You can find swagger API documentation here: http://localhost:5005/api/docs/index.html +## Development ```bash -# Build beast -$ make build - -# Run beast server -# Beast server will start running on port 5005 port by default -$ beast run -v - -# In another terminal Start the local deployment of the challenge, using the directory -$ curl -X POST localhost:5005/api/manage/deploy/local/ --data "challenge_dir=" - -# Or you can directly deploy the challenge using name in the remote -$ curl -X POST --data "action=deploy&name=" localhost:5005/api/manage/challenge/ - -# Wait for Beast to finish the image build and deployment of the challenge -# This might take some time. Have some snacks ready! -# Try connecting to the deployed service -$ nc localhost 10001 - ---- Menu --- -1.New note -2.Delete note -3.Help -4.Exit -choice > 4 +make check_format +go vet ./... +go test ./... +go test -race ./... +make build ``` -### Building documentation - -The documentation for the project lies in [/docs](/docs). We use `mkdocs` to automatically generate documentation from markdown. The configuration file for the same can be found at [mkdocs.yml](/mkdocs.yml). To view the documentation locally, create a virtual environment locally and install [requirements](/requirements-dev.txt). +PostgreSQL concurrency tests run when `BEAST_TEST_PG_DSN` is set. Redis integration tests run when `BEAST_TEST_REDIS_ADDR` and related credentials are set. The example deployment harness is destructive and opt-in: ```bash -$ virtualenv venv && source venv/bin/activate - -$ pip install -r requirements.txt +BEAST_RUN_INTEGRATION=1 make integration-test +``` -$ mkdocs serve +Build documentation with pinned Python dependencies from `requirements.txt`: -Serving on http://127.0.0.1:8000 +```bash +python3 -m venv .venv +. .venv/bin/activate +pip install --requirement requirements.txt +make cmdref +make docs ``` -## Contributing +## Teardown -We are always open for contributions. If you find any feature missing, or just want to report a bug, feel free to open an issue and/or submit a pull request regarding the same. +`scripts/teardown.sh` verifies the controller lock and process ownership before sending `SIGTERM`. It does not undeploy challenges or delete external PostgreSQL/Redis data. -For more information on contribution, check out our -[docs](./docs/Contribution.md). - -## Contact +```bash +./scripts/teardown.sh # stop only +./scripts/teardown.sh --purge-data # also remove $BEAST_HOME or $HOME/.beast +``` -If you have a query regarding the product or just want to say hello then feel -free to visit [chat.sdslabs.co](https://chat.sdslabs.co) or drop a mail at -[contact@sdslabs.co.in](mailto:contact@sdslabs.co.in) +The purge option is intentionally destructive for local Beast state and refuses unsafe target paths. ---- +## License -Made with :heart: by [SDSLabs](https://sdslabs.co) +[Apache License 2.0](LICENSE.md) diff --git a/_examples/README.md b/_examples/README.md index ca708107..bb3e2b8b 100644 --- a/_examples/README.md +++ b/_examples/README.md @@ -1,25 +1,25 @@ # Examples -> This directory contains a few challenges example for beast, which are properly tested and should work out of the box. +These directories demonstrate Beast challenge formats. They contain public test flags and intentionally weak sample services; run them only on disposable development workers. -### Configuration file samples: +- `static-chall`: static-only content. +- `service`, `xinetd-service`: service challenges. +- `web-php`, `web-php-mysql`: generated web challenges. +- `bare-docker`: custom Dockerfile. +- `compose-type`: constrained Compose deployment. +- `instanced-service`, `instanced-compose`: per-user instances. +- `simple`: generated bare environment. -* [Beast global configuration sample](./example.config.toml) +Validate before deployment: -### Sample Challenges - -* [Simple Challenge - Bare](./simple) -* [PHP Web challenge](./web-php) -* [PHP Web challenge with MySQL](./web-php-mysql) -* [Challenge with Static files only](./static-chall) -* [Xinted Service challenge with custom xinetd config](./xinetd-service) -* [Service challenge with auto-generated xinetd config](./service) -* [A bare challenge using docker](./docker-type) -* [Docker compose challenge](./compose-type) +```bash +beast verify --local-directory "$PWD/_examples/service" +``` -To test any of the above challenges, cd to \_example directory and use the below command: +Then deploy with the local CLI path: ```bash -$ curl -X POST localhost:5005/api/manage/deploy/local/ \ - --data "challenge_dir=$PWD/" +beast challenge deploy --local-directory "$PWD/_examples/service" ``` + +Both commands load the operator's validated `$HOME/.beast/config.toml`. Local deployment is an administrator/controller-host workflow and still uses the configured Docker worker, resource ceilings, PostgreSQL, and Redis. diff --git a/_examples/compose-type/README.md b/_examples/compose-type/README.md index d1f30a63..28d1731e 100644 --- a/_examples/compose-type/README.md +++ b/_examples/compose-type/README.md @@ -1,17 +1,10 @@ -# Docker Compose example (PHP + MySQL) +# Compose example (PHP + MySQL) -This example demonstrates a multi-container challenge deployed via Docker Compose. - -- `beast.toml` enables Compose mode using `docker_compose = "docker-compose.yml"`. -- `docker-compose.yml` defines an `app` container and a `mysql` container. - -Deploy locally: +This example uses Beast's constrained Compose schema. `${APP_PORT}` is allocated by Beast and mapped to the web container. Both services declare CPU, memory, PID, capability, and `no-new-privileges` controls. ```bash -curl -X POST localhost:5005/api/manage/deploy/local/ \ - --data "challenge_dir=$PWD/_examples/compose-type" +beast verify --local-directory "$PWD/_examples/compose-type" +beast challenge deploy --local-directory "$PWD/_examples/compose-type" ``` -After deployment, open: - -- http://localhost:10020 +Use `beast challenge show compose-type` to obtain the selected worker and allocated port. The challenge application itself serves plain HTTP; do not confuse it with the HTTPS Beast management API. diff --git a/_examples/example.config.toml b/_examples/example.config.toml index d26d65e9..665e37f9 100644 --- a/_examples/example.config.toml +++ b/_examples/example.config.toml @@ -1,10 +1,17 @@ # Base OS image that beast allows the challenges to use. -allowed_base_images = ["ubuntu:24.04", "ubuntu:18.04", "debian:bookworm"] +allowed_base_images = ["ubuntu:24.04", "debian:bookworm"] # For authentication purposes beast uses JWT based authentication, this is the # key used for encrypting the claims of a user. Keep this strong. jwt_secret = "CHANGE_ME_GENERATED_BY_SETUP" +# Public HTTPS origin of the optional static-content service. Leave empty when +# challenge assets are not published through Beast. +beast_static_url = "" + +# Minimum accepted value is 2m. Invalid/short values fall back to the default. +remote_sync_period = "2m" + #Health Prober, if active, starts a health prober on a thread which checks for #deployed challenges, containers and servers after every ticker_frequency period health_prober = false @@ -52,7 +59,7 @@ username = "" # Path to private SSH key for interacting with the server. (Leave empty for localhost) ssh_key_path = "" -# Port range for this server (format: START:END) - uses local_host_port_range if empty +# Port range for this worker (format: START:END) port_range = "10000:20000" # Status of remote server to be used @@ -108,7 +115,9 @@ password = "CHANGE_ME" dbname = "beast" host = "localhost" port = "5432" -sslmode = "prefer" +# Loopback development may use disable. Remote PostgreSQL must use verify-full +# and a CA bundle in sslrootcert. +sslmode = "disable" sslrootcert = "" [redis_config] @@ -116,6 +125,8 @@ host = "localhost" port = "6379" password = "CHANGE_ME" user = "beast" +# Remote Redis must enable TLS. ca_file may be empty only when system roots +# trust the server certificate. tls = false ca_file = "" server_name = "" diff --git a/_examples/instanced-compose/README.md b/_examples/instanced-compose/README.md index d6240e8d..79fa730e 100644 --- a/_examples/instanced-compose/README.md +++ b/_examples/instanced-compose/README.md @@ -1,89 +1,23 @@ -# Instanced Docker Compose Challenge Example +# Instanced Compose example -This is an example of an **instanced challenge using Docker Compose** - a multi-container challenge where each user gets their own isolated environment with a web server and database. +This challenge creates a per-user PHP/MySQL Compose project. Beast substitutes the allocated `${INSTANCE_PORT}` in `docker-compose.yml`; no default-value or arbitrary environment interpolation is accepted. -## Architecture - -``` -┌──────────────────────────────────────────┐ -│ User's Instanced Environment │ -│ ┌─────────────┐ ┌─────────────┐ │ -│ │ PHP/Apache │ ───▶ │ MySQL │ │ -│ │ (web) │ │ (db) │ │ -│ └─────────────┘ └─────────────┘ │ -│ │ │ -│ ▼ │ -│ Port: 31234 (dynamically assigned) │ -└──────────────────────────────────────────┘ -``` - -## Key Configuration - -In `beast.toml`: - -```toml -[challenge.metadata] -instanced = true -instance_expiration = 600 # 10 minutes - -[challenge.env] -docker_compose = "docker-compose.yml" -default_port = 8080 -``` - -In `docker-compose.yml`, use the `INSTANCE_PORT` environment variable: - -```yaml -services: - web: - ports: - - "${INSTANCE_PORT:-8080}:80" -``` - -## Challenge Details - -This is a SQL injection challenge: - -1. The login form is vulnerable to SQL injection -2. Bypass authentication to login as admin -3. The flag is stored in the `secrets` table - -### Solution - -``` -Username: admin' OR '1'='1' -- -Password: anything -``` - -Or use UNION-based injection to extract data directly. - -## Testing Locally +Validate and deploy the challenge definition: ```bash -# Build and run locally (for testing) -cd _examples/instanced-compose -docker-compose up -d - -# Access at http://localhost:8080 +beast verify --local-directory "$PWD/_examples/instanced-compose" +beast challenge deploy --local-directory "$PWD/_examples/instanced-compose" ``` -## Usage via Beast API +Spawn an instance through the TLS API: ```bash -# Spawn your instance -curl -X POST -H "Authorization: Bearer $TOKEN" \ - http://localhost:8080/api/instances/instanced-compose/spawn +curl --cacert "$HOME/.beast/secrets/tls.crt" \ + --header "Authorization: Bearer $TOKEN" \ + --request POST \ + https://localhost:5005/api/instances/instanced-compose/spawn +``` -# Response: -# { -# "instance_id": "abc123def456", -# "challenge_name": "instanced-compose", -# "hosted_address": "localhost", -# "port": 31234, -# "expires_at": "2024-01-15T10:40:00Z", -# "ttl_seconds": 600 -# } +Open `http://:` from the JSON response. Challenge traffic is plain HTTP in this example; the Beast management API remains HTTPS. -# Access your instance -open http://localhost:31234 -``` +The application intentionally contains SQL injection and public sample credentials/flags. Do not deploy it on production infrastructure. diff --git a/_examples/instanced-service/README.md b/_examples/instanced-service/README.md index 7710cbcf..969db20c 100644 --- a/_examples/instanced-service/README.md +++ b/_examples/instanced-service/README.md @@ -1,119 +1,29 @@ -# Instanced Service Challenge Example +# Instanced service example -This is an example of an **instanced challenge** - a challenge where each user gets their own dedicated container instance. +Each authenticated user receives a dedicated service container and a host port allocated from the selected worker's `available_servers..port_range`. `default_port = 9999` identifies the container port; the global `[instance_config]` controls default expiration, maximum extension, and per-user counts. -## Key Features - -- **Per-user isolation**: Each user spawns their own container -- **Automatic expiration**: Instances expire after a configurable time (default: 5 minutes) -- **Dynamic port allocation**: Ports are assigned from a configured range (not from the challenge config) - -## Configuration - -In `beast.toml`, the key settings for instanced challenges are: - -```toml -[challenge.metadata] -instanced = true # Enable instancing -instance_expiration = 300 # Optional: override default expiration (in seconds) - -[challenge.env] -# DO NOT specify ports for instanced challenges! -# Instead, use default_port to indicate which container port to expose -default_port = 9999 -``` - -## Global Configuration - -In your Beast `config.toml`, configure the instance settings: - -```toml -[instance_config] -local_host_port_range = "10000-11000" # Host port range for instances -default_expiration = 300 # Default TTL in seconds (5 minutes) -max_extension = 600 # Maximum extension time (10 minutes) -max_instances_per_user = 3 # Max concurrent instances per user -``` - -## API Usage - -### User Endpoints - -1. **Spawn an instance**: - ```bash - curl -X POST -H "Authorization: Bearer $TOKEN" \ - http://localhost:8080/api/instances/instanced-service/spawn - ``` - -2. **Get your instance**: - ```bash - curl -H "Authorization: Bearer $TOKEN" \ - http://localhost:8080/api/instances/instanced-service - ``` - -3. **Get all your instances**: - ```bash - curl -H "Authorization: Bearer $TOKEN" \ - http://localhost:8080/api/instances - ``` - -4. **Extend instance lifetime**: - ```bash - curl -X POST -H "Authorization: Bearer $TOKEN" \ - -d "seconds=300" \ - http://localhost:8080/api/instances/instanced-service/extend - ``` - -5. **Kill your instance**: - ```bash - curl -X DELETE -H "Authorization: Bearer $TOKEN" \ - http://localhost:8080/api/instances/instanced-service - ``` - -### Admin Endpoints - -1. **List all instances**: - ```bash - curl -H "Authorization: Bearer $ADMIN_TOKEN" \ - http://localhost:8080/api/admin/instances - ``` - -2. **Kill any instance**: - ```bash - curl -X DELETE -H "Authorization: Bearer $ADMIN_TOKEN" \ - http://localhost:8080/api/admin/instances/{instance_id} - ``` - -3. **Kill all instances for a challenge**: - ```bash - curl -X DELETE -H "Authorization: Bearer $ADMIN_TOKEN" \ - http://localhost:8080/api/admin/instances/challenge/instanced-service - ``` +```bash +export BEAST_URL=https://localhost:5005 +export BEAST_CA="$HOME/.beast/secrets/tls.crt" -## Response Example +curl --cacert "$BEAST_CA" \ + --header "Authorization: Bearer $TOKEN" \ + --request POST "$BEAST_URL/api/instances/instanced-service/spawn" -When spawning an instance, you'll receive: +curl --cacert "$BEAST_CA" \ + --header "Authorization: Bearer $TOKEN" \ + "$BEAST_URL/api/instances/instanced-service" -```json -{ - "instance_id": "a1b2c3d4e5f6", - "challenge_name": "instanced-service", - "hosted_address": "localhost", - "port": 31234, - "created_at": "2024-01-15T10:30:00Z", - "expires_at": "2024-01-15T10:35:00Z", - "ttl_seconds": 300 -} -``` +curl --cacert "$BEAST_CA" \ + --header "Authorization: Bearer $TOKEN" \ + --request POST --data-urlencode 'seconds=300' \ + "$BEAST_URL/api/instances/instanced-service/extend" -Connect to your instance: -```bash -nc localhost 31234 +curl --cacert "$BEAST_CA" \ + --header "Authorization: Bearer $TOKEN" \ + --request DELETE "$BEAST_URL/api/instances/instanced-service" ``` -## Challenge Details +The spawn response supplies `hosted_address`, `port`, and expiration data. Connect with `nc `. -This example is a simple buffer overflow challenge: -- The `vulnerable()` function uses `gets()` which doesn't check bounds -- Overflow the 64-byte buffer to overwrite the return address -- Redirect execution to the `win()` function to get the flag +Administrator instance routes are documented in the live Swagger UI. This example contains a deliberately vulnerable program and a public test flag; use a disposable worker. diff --git a/docs/APIAuth.md b/docs/APIAuth.md index a2a2fb1b..89b134a6 100644 --- a/docs/APIAuth.md +++ b/docs/APIAuth.md @@ -1,62 +1,49 @@ # Authentication -Authentication is done to provide restricted access only to the organizers. Authentication is done using asymmetric encryption. After authentication, JWT tokens are used to provide access for further usage of API's. +Beast uses password authentication and HMAC-signed JWT bearer tokens. There is no SSH-key challenge-response login and authorization cannot be disabled from the command line. -## Usage +## Credentials -### Configuration +- Passwords must contain 12–128 bytes and cannot be whitespace-only. +- Contestant usernames are 3–12 lowercase letters, digits, dots, underscores, or hyphens. +- `jwt_secret` must contain at least 32 bytes and must be unique per deployment. +- Tokens expire after six hours. Password-reset tokens have a separate, restricted claim type. -For signing JWT tokens using **HMAC** algorithm `jwt_secret` is required which can be configured in the beast global config (config.toml) as: +Create the first administrator during `beast init`. Additional authors/admins can be created with the CLI on the controller host. -```toml -jwt_secret = "beast_jwt_secret_SUPER_STRONG_0x100010000100" -``` - -The SSH keys are used for assymetric authentication which can be registered directly from the terminal using: - -`beast create-author --name --email --publickey ` - -Or it can be given in the challenge description(beast.toml) : +## Login -```toml -[author] -ssh_key = "" +```bash +curl --cacert "$HOME/.beast/secrets/tls.crt" \ + --request POST https://localhost:5005/auth/login \ + --data-urlencode 'username=admin' \ + --data-urlencode 'password=' ``` -This key gets added in the database when beast.toml is validated. +The response contains `token`, `role`, and `message`. Supply the token exactly as: -## Flow +```text +Authorization: Bearer +``` -Once the public key is registered in the database, the user can get a JWT token through the following steps : -* First make a GET request on URL : `/auth/` -* The response will be of the format : +The CLI performs the same flow and verifies TLS: -``` JSON -{ - "challenge" : "Challenge String", - "message" : "" -} +```bash +beast getauth --host https://localhost:5005 \ + --ca-file "$HOME/.beast/secrets/tls.crt" \ + --username admin ``` -* The challenge must be decrypted using *ssh private key* and then a POST request has to be made on URL: `/auth/`along with POST form data: `decrmess=` -* You will get a response like this : -``` JSON -{ - "token" : "YOUR_AUTHENTICATION_TOKEN", - "role" : "", - "message" : "" -} -``` +Login attempts are rate-limited, unknown users receive the same public failure as bad passwords, and banned users cannot obtain tokens. Keep the API behind network access controls as rate limiting is not a substitute for perimeter protection. -* Now to access any restricted route you need to add this JWT token in the HTTP header as : -``` HTTP -Authorization: Bearer YOUR_AUTHENTICATION_TOKEN -``` +## Roles -### Alternative +- `contestant`: competition and instance APIs. +- `author` / `maintainer`: challenge-management routes only for challenges they own or maintain; uploaded configuration email ownership is checked. +- `admin`: bulk/scheduled operations, controller-local path deployment, remote synchronization, configuration mutation, static-service management, user control, and administrative instance operations. -If you have the `beast` binary with you, you can also use command : +Authorization is checked both at route level and against challenge ownership for sensitive operations such as logs and container execution. -`beast getauth --identity --username --host ` +## Browser access -This command will give you the JWT token for usage in other APIs by adding in the HTTP header. \ No newline at end of file +CORS is disabled unless `server.allowed_origins` is populated. Origins are exact and credentialed cross-origin requests are disabled. Never use a wildcard origin for an administrative frontend. diff --git a/docs/Architecture.md b/docs/Architecture.md index 88051abe..4fa6194c 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -1,51 +1,40 @@ # Architecture -This section deals with how the different pieces of beast fall together to create a robust and flexible -deployment pipeline. For internals of each section itself go to respective documentation pages. +## Controller -Important components of beast are described below: +The Beast process owns the HTTPS API, scheduler, bounded worker queue, health/instance reconcilers, PostgreSQL pool, Redis client, and remote-worker registry. A filesystem lock prevents two controllers from using the same local state directory. Shutdown drains controller-owned goroutines and connections but intentionally leaves deployed challenges running. -### API Server +## Durable and coordination state -API server is the interface provided by beast to interact with the underlying application. All the actions performed by beast -are propogated by an external agent from this point. The API server is a HTTP REST API service built on top of go-gin framework. -For authentication purposes it uses JWT which can be optionally turned off when running the server. +PostgreSQL is the durable source for users, challenge metadata, ownership, submissions, scores, runtime identifiers, and deployed ports. Critical submission and dynamic-flag transitions use transactions, row locks, and unique indexes. Ports are unique per deployment server. -Requests for beast API are split across five namespaces +Redis stores namespaced coordination data, port allocation, cached metadata, and expiring instance markers. Atomic Lua/pipeline operations protect shared updates. Expiry notifications accelerate cleanup, while periodic reconciliation is the fallback. -* **manage**: Challenge management related APIs. Ex. Deploy, Undeploy etc. -* **info**: Challenge information related APIs. Ex. ChallengeInfo etc. -* **status**: Challenge status related APIs. Ex. Challenge status -* **remote**: Beast remote repository related APIs. Ex. SyncRemote -* **config**: Beast global configuration related APIs. Ex. Reload Config -* **notification**: Competition notification related APIs. Ex. Add Notifications -* **admin**: Competition related APIs requiring admin prevalages. Ex. Competition Stastics +## Deployment workers -### Manager +Challenges run on the local Docker daemon or active SSH workers. Worker selection is round-robin. Remote host keys are verified from `known_hosts`; unavailable active workers fail startup rather than silently reducing capacity. -This part is the brains of beast and does all the action corresponding to lifecycle management of challenges along with managing configuration -and authentication. The part lives inside `core/` directory and uses helpers/interfaces from inside of `pkg/` and `/utils`. +Challenge builds apply CPU, memory, and PID limits. Generated service images run challenge commands as an unprivileged user unless an explicit entrypoint/custom image requires otherwise. Docker/Compose authors remain trusted because build instructions execute with Docker authority. -### Container Runtime +## Request and task flow -Container runtime is a wrapper around docker client library which provides beast with helper function to deal with underlying container runtime. +1. TLS and request-size limits are applied. +2. Authentication establishes JWT claims and role middleware. +3. Ownership checks authorize challenge-specific management operations. +4. Strict configuration/path validation runs before staging. +5. Work enters a bounded, de-duplicating queue. +6. The selected local/remote runtime performs build or lifecycle work. +7. Completion and failures are recorded and returned/logged; database state changes are transactional where multiple relations must move together. -Currently this only supports `docker` as an underlying container runtime but we will soon be creating a generalized interface which can be fulfilled -by most of the container runtimes. Something similar on the lines of CRI(Container Runtime Interface for kubernetes). +Scheduled tasks never overlap with themselves. Health probes use timeouts, verify HTTPS certificates, reject redirects, and bound response bodies. -### Queue and Workers +## Static service -All the tasks performed by beast are non synchronous and are handled by a queue. Whenever a new action is performed corresponding to beast API -a new task is created using the action configuration and pushed to an internal queue. The result is returned immediately by the API server notifying about -the start of the process. +Static challenge content is copied to normalized public directories. A digest-pinned `nginx-unprivileged` image runs as UID 101 and receives only per-challenge read-only mounts, never the whole staging tree. The service listens on container port 8080 and is published on host port 8034; production deployments should terminate public TLS in front of it. -Beast workers are the actual underlying goroutines which handles the tasks assigned by the Queue. They perform the required task and then take on -the next. Consider them as the threadpool for beast. +## Trust boundaries -## Challenge Flow - -![Challenge Deployment Flow](res/challenge-flow.png) - -## Deployment Pipeline - -![Challenge Deployment Pipeline](res/deployment-pipeline.png) +- API users are untrusted; inputs, body sizes, filenames, archive entries, and authorization are validated. +- Challenge authors are trusted to supply build code, but paths and dangerous Compose runtime controls are constrained. +- Docker and remote SSH credentials are highly privileged and must be protected as root-equivalent. +- PostgreSQL, Redis, Git, SMTP, and webhook networks are external trust boundaries and require authenticated, verified transport when remote. diff --git a/docs/ChallConfig.md b/docs/ChallConfig.md index cc2bea22..10cd0fde 100644 --- a/docs/ChallConfig.md +++ b/docs/ChallConfig.md @@ -1,172 +1,119 @@ -# Challenge Config +# Challenge configuration -You can think of beast as a wrapper around the underlying container runtime with a lot of addtional functionalities -including lifecycle management, health checks etc. On a very high level you can say that **beast is to -CTF challenges what Docker is to container.** +Every challenge directory contains a strict TOML file named `beast.toml`. Unknown keys are rejected. Generate a parseable static scaffold with `beast new`, then validate changes with: -Now that we know what is beast actually is(just a wrapper around challenge containers) the question is Why Beast? -The answer to which lies in Why docker when you have runc? - -Similar to what docker provides, a high level abstraction to manage the lifecycle, network, state etc among other things -for containers, beast provides a nice abstraction to create, manage and deploy challenges. This allows a challenge creator to -focus on creating the challenge rather than thinking of everything else. It exposes a very little overhead to the -side of Challenge Creator apart from creating a challenge and handles everything itself. - -Challenge configuration is the heart of challenge deployment using beast. You can think of it as the blueprint for the -challenge which requires some metadata holding instructions to beast on how to handle the challenge. Think of it as what Dockerfile is -to Docker image. - -Internally since everything we do in beast revolves around containers, this configuration is also used to generate a _Dockerfile_ which is -then used to build the images for the underlying atomic elements to a challenge a container. Think of this challenge coniguration as -a nice wrapper around the Dockerfile itself which is more understandable from a Security Researcher perspective than all the Jargon -in Dockerfile. - -## Structure +```bash +beast verify --local-directory /absolute/path/to/challenge +``` -The configuration corresponding to a challenge is writtern to a file named `beast.toml` in the root of the challenge directory. -The configuration itself is very minimilistic and is provided in TOML format(mostly because of it's highly readable syntax). +Challenge names must match `^[a-z0-9][a-z0-9._-]{0,63}$`. Referenced files/directories must be relative, regular entries that resolve inside the challenge root; symlinks and path traversal are rejected during validation/staging. -There are three main sections to the configuration the structure of which is as below. +## Author and maintainers ```toml -# Section containing the details corresponding the the author of challenge [author] +name = "Author Name" +email = "author@example.com" -# Stores details corresponding to metadata of challenge -[challenge.metadata] - -# Contains the environment or deployment details of the challenge -[challenge.env] +[[maintainer]] +name = "Maintainer Name" +email = "maintainer@example.com" ``` -All the keys accepted by these sections are mentioned below: - -### Author - -This section contains the metadata about the author, it is used for various purposes among which the most important -one is giving the challenge environment access to Author for testing and debugging purposes. +Email is required and must be canonical. Existing users identified by email become challenge managers; ownership is enforced by management, logs, and execution endpoints. -This section accepts the following fields +## Metadata ```toml -# Optional fields -name = "" - -# Required Fields -email = "" -ssh_key = "" # Public ssh Key of the author. +[challenge.metadata] +name = "example-web" +type = "web:php" +flag = "flag{replace-me}" +dynamicFlag = false +difficulty = "medium" +description = "Example challenge" +tags = ["web", "php"] +points = 500 +minPoints = 100 +maxPoints = 500 +maxAttemptLimit = 0 +preReqs = [] +assets = ["download.zip"] +additionalLinks = ["https://example.invalid/rules"] +instanced = false +instance_expiration = 300 + +[[challenge.metadata.hints]] +text = "A bounded hint" +points = 50 ``` -### Challenge Metadata - -This section contains metadata information about the challenge and is consumed by beast to be provided to -the user. +`flag` may be empty only when `dynamicFlag = true`. `maxAttemptLimit = 0` means unlimited attempts. Point ranges must be internally consistent. Prerequisites must be valid challenge names, links must be HTTP(S), and each asset must exist beneath `static_dir` (default `public`). -Structure of the sections with the acceptable fields are: +Instanced challenges create per-user runtime instances. Expiration falls back to the global instance default when omitted; extensions and per-user counts are capped globally. -```toml -# Required Fields -flag = "" # Flag for the challenge -name = "" # Name of the challenge -type = "" # Type of the challenge, one of - Get available types from /api/info/types/available -description = "" # Descritption for the challenge. - -# Optional fields. -tags = ["", ""] # Tags that the challenge might belong to, used to do bulk query and handling eg. binary, misc etc. -hints = ["", ""] -minPoints = 0 # Minimum points given to the player for correct flag submission. Beast has dynamic scoring, so a range of points is specified -maxPoints = 0 # Maximum points given to the player for correct flag submission. Beast has dynamic scoring, so a range of points is specified -assets = ["", ""] # Name of assets to be provided which are included in the ./static folder -``` +## Generated environments -### Challenge Environment - -This is the core of deployment configuraiton for the challenge which is consumed by beast. -It contains all the information required by beast to manage the lifecycle of the challenge. - -Acceptable fields for this section are: +Non-static generated challenge images use `[challenge.env]`: ```toml -# Ports to reserve for the challenge, we bind only one of these to host other are for internal communictaions only. -# Should be within a particular permissible range. -ports = [0, 0] -default_port = 0 # Default port to use for any port specific action by beast. - -# Port mapping is the array of port mapping from host to container. -# The first port mentioned in the mapping is the host port and the second is the container port. -# Port Mapping is given preference as compared to ports, so if you have a port and the same port in mapping -# then the host port corresponding to container port in the port mapping. -port_mappings = ["10005:80"] - - -# Dependencies required by challenge, installed using default package manager of base image apt for most cases. -apt_deps = ["", ""] - - -# A list of setup scripts to run for building challenge enviroment. -# Keep in mind that these are only for building the challenge environment and are executed -# in the iamge building step of the deployment pipeline. -setup_scripts = ["", ""] - - -# A directory containing any of the static assets for the challenge, exposed by beast static endpoint. -static_dir = "" - - -# Command to execute inside the container, if a predefined type is being used try to -# use an existing field to let beast automatically calculate what command to run. -# If you want to host a binary using xinetd use type service and specify absolute path -# of the service using service_path field. -run_cmd = "" - - -# Similar to run_cmd but in this case you have the entire container to yourself -# and everything you are doing is done using root permissions inside the container -# When using this keep in mind you are root inside the container. -entrypoint = "" - - -# Relative path to binary which needs to be executed when the specified -# Type for the challenge is service. -# This can be anything which can be exeucted, a python file, a binary etc. +[challenge.env] +ports = [8080] +default_port = 8080 +apt_deps = [] +setup_scripts = ["setup.sh"] +static_dir = "public" +base_image = "ubuntu:24.04" +run_cmd = "./server" service_path = "" +web_root = "challenge" +entrypoint = "" +docker_context = "" +xinetd_conf = "" +traffic = "tcp" +[[challenge.env.var]] +key = "FLAG_FILE" +value = "secrets/flag" +``` -# Relative directory corresponding to root of the challenge where the root -# of the web application lies. -web_root = "" +Rules: +- One to three unique container ports in `1..65535` are allowed; `default_port` must be one of them. +- `traffic` is `tcp` or `udp`. +- `base_image` must be in the administrator allowlist. +- Setup scripts run at image-build time and therefore are trusted code. +- Environment `value` is a path to a file inside the challenge, not a literal secret. Beast reads the file and injects its content. +- `run_cmd` and `entrypoint` are mutually exclusive. An explicit entrypoint may run as the image user/root; use it only when required. +- `docker_context` names a Dockerfile inside the challenge. Custom Dockerfiles are trusted build code. -# Any custom base image you might want to use for your particular challenge. -# Exists for flexibility reasons try to use existing base iamges wherever possible. -base_image = "" +Static challenges need only `static_dir`; no port or runtime command is required. +## Compose environments -# Docker file name for specific type challenge - `docker`. -# Helps to build flexible images for specific user-custom challenges -docket_context = "" +```toml +[challenge.env] +docker_compose = "docker-compose.yml" +default_port_var = "APP_PORT" +static_dir = "public" +``` +Compose port bindings use variables assigned by Beast, for example `${APP_PORT}:8080`. Only port interpolation is accepted. The parser rejects unknown fields and security-sensitive runtime controls including privileged mode, host network/PID/IPC, devices, Docker socket access, unsafe mounts, namespace sharing, added capabilities, and arbitrary restart ownership. Build contexts and Dockerfiles must remain inside the challenge directory, and every service must comply with global resource ceilings. -# Environment variables that can be used in the application code. -[[var]] - key = "" - value = "" +When `docker_compose` is present, generated-image fields such as `run_cmd`, `entrypoint`, `base_image`, `apt_deps`, and `setup_scripts` are ignored. -[[var]] - key = "" - value = "" +## Resources -# Protocol supported by the challenge, currently supported are tcp and udp. -traffic = "tcp"/"udp" +```toml +[resource] +cpu_shares = 256 +cpuslimit = 0.20 +memory_limit = 268435456 +pids_limit = 64 ``` -If you want to checkout some example challenge configuration, checkout `_example` directory in the -root of the repository. It has a bunch of challenge templates example to get started with. Pick one from -there and start building your own challenge. +Omitted or zero values inherit global defaults. A challenge may request less, never more, than the configured global ceilings. -## Note +## Archives and uploads -We currently don't do automatic port management for challenge, it is mostly due to historic -reasons. Beast still handles challenge deployment for [Backdoor](https://backdoor.sdslabs.co/) which has a different database -as that of beast and to have the port synced among these two database is not easy so for the initial -milestone of beast we targatted static ports. +Uploaded ZIP/TAR content is bounded by entry count and expanded size. Absolute paths, `..`, duplicate paths, symlinks, devices, FIFOs, sockets, and overwrite attempts are rejected. Staging copies regular files only and never follows links. diff --git a/docs/ChallTypes.md b/docs/ChallTypes.md index 90f2b46e..a936908b 100644 --- a/docs/ChallTypes.md +++ b/docs/ChallTypes.md @@ -1,66 +1,29 @@ -# Challenge Types +# Challenge types -## Service Challenge +## Static -Any service whether it is a binary file, or a shell script, which needs to be instantiated on every connection can be easily hosted using `service` type challenge. **Xinetd** is for hosting these type of challenges inside a docker container. +`type = "static"` publishes files from `static_dir` (default `public`) through the shared unprivileged static service. Static challenges do not receive a runtime container or port. -###Primary Requirements +## Service -```toml -# Relative path to binary or script which needs to be executed when the specified -# Type for the challenge is service. -# This can be anything which can be exeucted, a python file, a binary etc. -service_path = "" -``` +`type = "service"` hosts a program through xinetd. Set `service_path` to a regular executable/script inside the challenge and provide one or more `ports`. An optional `xinetd_conf` replaces the generated service configuration. -## Web Challenge +## Web -Web challenges are hosted using the corresponding images from Dockerhub. Currently only these types are supported: +Web types begin with `web`, such as the supported PHP/Python/Node variants returned by configuration helpers. Set `web_root` and ports, or provide a validated `docker_context`/`docker_compose` for a custom web stack. -* Node -* Python : Django and Flask -* Php +## Bare/custom -###Primary Requirements +`type = "bare"` uses a generated base image and requires `run_cmd` or `entrypoint`. Generated `run_cmd` execution uses the unprivileged challenge user. An explicit entrypoint controls the whole container and may execute with its image user/root privileges, so it should be treated like a custom Dockerfile. -```toml -# Relative directory corresponding to root of the challenge where the root -# of the web application lies. -web_root = "" -``` +## Custom Dockerfile -## Static Challenge +Set `docker_context` to the Dockerfile path inside the challenge. Beast validates containment and applies build/run resource controls, but Dockerfile instructions are trusted build code with Docker-daemon impact. -All the challenges which requires the hackers to only have static files comes under `static` challenges. All the files are mounted on a single container which serves all the static files to the hackers. +## Docker Compose -## Bare Challenge +Set `docker_compose` to a Compose file inside the challenge. Beast accepts a constrained service schema, validates resource ceilings and build contexts, allocates port variables, and rejects host-level privilege controls. See [Challenge configuration](ChallConfig.md#compose-environments). -A challenge which requires high level of customization can be hosted using `bare` challenge. In these case, a bare base image is provided with exposed ports. +## Instanced challenges -###Primary Requirements - -```toml -# Command to execute inside the container, if a predefined type is being used try to -# use an existing field to let beast automatically calculate what command to run. -run_cmd = "" - -# OR - -# Provide a script to run on startup of container -# Similar to run_cmd but in this case you have the entire container to yourself -# and everything you are doing is done using root permissions inside the container -# When using this keep in mind you are root inside the container. -entrypoint = "" -``` - -## Docker Challenge - -Authors might have tested the challenges in a isolated docker environment and might not want to port the challenge to one of these types. So they can use `docker` type challenge in which you can provide your own docker context file and ports. - -###Primary Requirements - -```toml -# Docker file name for specific type challenge - `docker`. -# Helps to build flexible images for specific user-custom challenges -docket_context = "" -``` +Any supported runtime type may set `instanced = true`. Beast then creates isolated per-user instances with Redis-backed expiry, bounded extension, and administrator cleanup endpoints. Static-only challenges are not meaningful as instanced runtimes. diff --git a/docs/Deployment.md b/docs/Deployment.md index cd04187d..d4443d08 100644 --- a/docs/Deployment.md +++ b/docs/Deployment.md @@ -1,26 +1,18 @@ # Deployment -Deploying challenge is completely handled by beast, once the author is done with creating the required -challenge he can invoke deploy endpoints from beast to trigger the deployment pipeline for the challenge. +Beast can deploy a validated local challenge directory or synchronize the `challenges/` tree from configured SSH Git remotes. Active remotes are treated as sources of trusted build code. -The deployment model for beast is based on `git`, you can think of a git repository as a single source of -truth for all the challenge configuraiton. Using this `gitops` based approach provides a lot of benifits -in terms of flexibility and robustness of the applications. +The deployment pipeline validates configuration and paths, copies regular files into private staging, builds a generated/custom image or constrained Compose project under resource limits, selects a worker, starts the runtime, records identifiers/ports transactionally, and publishes normalized static assets when configured. -Every challenge that we create/add to the repository we can first verify is working and then can also do an -automatic deployment for the same. This is quite helpful for wargames like website where earlier for each challenge you -would have to manually deploy the challenge. +Remote synchronization and startup fail when an active remote/worker is unavailable; Beast does not silently operate with incomplete capacity. Periodic synchronization is opt-in with `beast run --periodic-sync`, and the scheduler prevents overlapping runs of the same task. -Using this gitops based approach provides us with all the benifits that modern days deployment pipelines have. Also, -it helps to easily extend the use cases around how beast can be used in different types of scenarios like for Jeopardy style -challenges, Wargames websites, CTF competitions etc. +Before production deployment: -## Flow +- review every setup script, Dockerfile, entrypoint, and Compose image as executable trusted code; +- pin base images by digest where reproducibility is required; +- keep flags/secrets out of Git and image layers, using validated file-backed environment values or runtime mechanisms; +- enforce worker firewall rules around allocated port ranges; +- terminate public challenge HTTP/TCP services appropriately and keep the Beast management API private over HTTPS; +- verify backup and restore procedures independently of controller shutdown. -![Challenge Deployment Flow](res/deployment-pipeline.png) - -## Note - -There are a lot of features which we can have when using a git repository as the source of truth for the application. -Beast still does not make use of all of them. There are a few features which are still in pipeline, in the same context -you can hope to see them in future releases of beast. +Normal controller shutdown preserves deployed workloads. Use explicit challenge undeploy/purge operations when runtime removal is intended. diff --git a/docs/Documentation.md b/docs/Documentation.md index 5a2579ad..534ce053 100644 --- a/docs/Documentation.md +++ b/docs/Documentation.md @@ -1,16 +1,5 @@ -# Beast Documentation +# Documentation map -This directory contains documentation related to beast and will guide you through flow, architecture usage and gotchas of beast. +Use [Setup](Setup.md) for installation and operator security, [Getting started](GettingStarted.md) for the first challenge, [Usage](Usage.md) for CLI/API operation, and [Challenge configuration](ChallConfig.md) for the strict schema. -Move over to any of the below pages to know more about beast. - -## Index - -* [Usage](./Usage.md) -* [Setup](./Setup.md) -* [Getting Started](./GettingStarted.md) -* [Features](./Features.md) -* [Architecture](./Architecture.md) -* [Authentication Flow](./APIAuth.md) -* [Challenge Configuration](./ChallConfig.md) -* [Deployment](./Deployment.md) +The [Architecture](Architecture.md) page documents state ownership and trust boundaries. The [command reference](cmdref/beast.md) is generated from the current binary. The running controller serves current Swagger API documentation at `/api/docs/index.html` over HTTPS. diff --git a/docs/Features.md b/docs/Features.md index 4625db7a..f53cc201 100644 --- a/docs/Features.md +++ b/docs/Features.md @@ -1,71 +1,28 @@ # Features -### Git based source of truth +## Challenge lifecycle -- Single or multiple git repositories as reliable source of truth for all the challenges. +- Local directories or one/more SSH-authenticated Git remotes as challenge sources. +- Static, generated service/web/bare images, custom Dockerfiles, and constrained Compose projects. +- Deploy, undeploy, redeploy, purge, logs, status, health probes, and administrator-controlled container execution. +- Bounded asynchronous worker queue with task de-duplication and surfaced failures. +- Local and SSH Docker workers with per-worker port ranges and round-robin placement. -- Easy to collaborate similar to an open source application where everyone can give their reviews on your contribution. +## Competition services -- Automatic deployment of challenges by using a triggering pipeline in conjunction with beast web server, this can - be configured in many ways: - - Execute a dry run on the challenge using beast. - - Using github webhook trigger deploy on beast when a challenge in pushed. +- Password login with role-scoped JWT authorization. +- Contestant registration/password reset, submissions, hints, prerequisites, attempt limits, and cheating records. +- Optional dynamic scoring and freeze/unfreeze leaderboard behavior. +- Per-user instances with expiration, extension limits, reconciliation, and administrator cleanup. +- Slack/Discord notifications and SSE updates. -### Container based Isolation +## Safety controls -We use containers as the atomic source of handle for each of our challenge. They provides us with an isolated and secure -environment for the challenges. +- Mandatory HTTPS API with timeouts, request-size bounds, strict CORS, and controlled panic responses. +- Strict global/challenge TOML and constrained Compose parsing. +- Contained archive extraction and staging that reject traversal, links, special files, duplicates, and expansion abuse. +- Default/hard-ceiling CPU, memory, and PID limits. +- Verified SSH host keys and mandatory verified transport for remote PostgreSQL/Redis. +- Unprivileged, digest-pinned static service with per-challenge read-only mounts. -- Currently only docker based container runtime support is available but we are extending to create a generalized - container runtime interface. This also means your documentation can now easily stand on its own, without always - plementation to support multiple providers similar to what kubernetes does. - -- Optionally security or sandboxing capabilities can be further enhanced by using more secure runtime like `runsc` in place - of runc. - -- We are also looking to support VM based implmentation in place of these containers such as firecracker, kata containers, - intel clear containers etc. - -### Easy Configuration - -Beast provides an easy configuration interface which allows the challenge creator to focus on only one problem which -is creating the challenge rather than thinking about the deployment scenarios for the same. There is a minimal overhead -due to simplicity of configuration that the author goes through during challenge creation. - -Configuration is even less of a pain due to great sensible defaults provided beast which works for most of the cases but are of -course configurable. - -To know more about configuration parameters provided by beast move to [this section](ChallConfig.md) - -### SSH support for challenge instances - -Beast provides challenge author access to each instance of all the challenges so that these challenges can be -debugged on the fly in case there is a need for. This also means that challenge author can test/debug the challenges before publishing them. - -For challenges which needs high degree of customization the author can create the environment by `SSH`ing to the container -and then export the running container image getting a tarball which can be used later to reproduce the -environments. - -### Miscellaneous - -- Web and Command line interface to perform actions. - -- REST API interface for the entire ecosystem. The competition related APIs can be integrated with your choice of frontend platform. - -- Ability to host full fledged CTF competition on beast itself. - -- An optional automated health check service to periodically check the status of challenges and report if there is - some sort of problem with one. - -- Single source of truth for all the static content related to all the challenges making it easy to debug, monitor and manage - static content through a single interface. - -- Support for various notification channels like slack, discord. - -- Everything embedded to a single go binary which can be easily used anywhere. - -- Extensible and flexible structure for easier development and feature introduction, some features we are exploring to support - with beast are: - - Kubernetes(k8s or k3s) based control plane to manage the lifecycle of challenges. - - Multi server support. - - Dry run support to help locally support development of challenges. +Docker-backed builds still execute trusted organizer code with root-equivalent daemon authority. These controls reduce mistakes and runtime exposure; they do not make hostile Dockerfiles or kernel exploits safe. diff --git a/docs/GettingStarted.md b/docs/GettingStarted.md index e6f676af..5d948f37 100644 --- a/docs/GettingStarted.md +++ b/docs/GettingStarted.md @@ -1,152 +1,13 @@ -# Getting Started +# Getting started -Beast is a tool for automatic deployment of CTF type challenges, the intial aim of the project -was to asist the deployment for challenges on backdoor.sdslabs.co, but since its inception beast has grown a lot beyond that scope. -It is a general tool for deployment of Jeopardy style CTF challenges and is not coupled with backdoor anymore. +1. Follow [Setup](Setup.md) to build Beast, configure TLS/PostgreSQL/Redis, and create an administrator. +2. Start the controller with `beast run --health-probe`. +3. Authenticate with `beast getauth --host https://localhost:5005 --ca-file "$HOME/.beast/secrets/tls.crt" --username `. +4. Create an empty challenge directory and run `beast new` inside it. +5. Edit the generated `beast.toml` and place static files in `public/`. +6. Run `beast verify --local-directory "$PWD"`. +7. As an administrator on the controller, deploy locally with `beast challenge deploy --local-directory "$PWD"`; otherwise commit the directory beneath `challenges/` in a configured SSH Git remote or upload a bounded ZIP through the API. -The main hurdle for deployment of such challenges is the requirement of stong isolation -of the environment in which the challenges are being deployed, docker provides us with all the -sandboxing we need with minimum overhead. There are more secure runtime like `runsc`(gVisor) which can -be used to improve the sandboxing capabilities of the containers. +Use lowercase challenge names containing only letters, digits, dots, underscores, and hyphens. Do not include symlinks, device files, sockets, or paths outside the challenge root. -There are a lot of features that comes embedded with beast, take a look [here](Features.md) - -Beast comes in with an embedded web server which can be used as an interface for interacting with beast. -The web server is built with gin framework and is very performant, to run the server with debugging mode on -use the following command - -```bash -beast run -v -p 3333 -``` - -To interact with beast you need to first authenticate yourself, currently this process is a little bit tedious since we -don't currently have a strict database storing the details of our users. To check out how the authentication -flow works for beast take a look [here](APIAuth.md) - -Once you have the authentication token with you all you need to do is Embed the token in Headers of your request as -`Authorization: Bearer `. - -There is swagger generated API documentation with the details of endpoints exposed by beast which can be used for interacting -with the web server. - -## Deploying your first challenge - -In this section we will try to create a new challenge and deploy it using beast. For the simplicitiy of this tutorial we are -deploying a simple buffer overflow challenge. - -The source code for our challenge file is: - -```c -#include -#include - -int sample() -{ FILE *ptr_file; - char buf[100]; - - ptr_file = fopen("flag.txt","r"); - if (!ptr_file) - return 1; - - while (fgets(buf,100, ptr_file)!=NULL) - fprintf(stderr, "%s",buf); - fclose(ptr_file); - return 0; -} - -void test() -{ char input[50]; - gets(input); - sleep(1); - fprintf(stderr, "ECHO: %s\n",input); -} - -int main() -{ test(); - return 0; -} -``` - -Create a new directory for the challenge and create the source code file(`pwn_me.c`) with the above contents. - -Create a file beast.toml with the following contents which defines the configuration of the challenge - -```toml -[author] -name = "fristonio" # Name of the challenge creator -email = "deepeshpathak09@gmail.com" # Email for contact -ssh_key = "ssh-rsa AAAAB3NzaC1y..." # Public SSH key for the challenge author - -[challenge.metadata] -name = "PWN-TEST" # Name of the challenge, must be same as the directory name. -flag = "FLAG{TEST_CHALLENGE_PWNED}" # Flag for the challenge -type = "service" # Type of challenge - -# This section defines the environment for the challenge -[challenge.env] - -# Define the dependencies we might need for the challenge. -# For example in this case we need gcc for compilation of the source file -apt_deps = ["gcc", "socat"] - -# The relative path of the binary or executable which we should -# run for each connection to the challenge. -service_path = "pwn" - -# Port to run the challenge on. -ports = [10003] - -# Since we still haven't defined how we are going to compile the source -# code, these scripts are for setting up the environment. -setup_scripts = ["setup.sh"] -``` - -The above configuration is simple and straightforward, from the perspective of the challenge -creator it simply asks how will he run the challenge locally. So for example he needs to install -some dependencies first like gcc, then he compiles the source and generates a binary, then he serves -the binary by exposing it as a service at some port. So from a challenge creator perspective -this is not a big hurdle. - -Let's see how our setup scripts look like. - -```bash -set -e - -gcc -o pwn pwn_me.c -``` - -* All the commands that you execute are executed from the within the root of the challenge -directory. - -It's simple we just compile the source code. - -The final step is important and needed to be taken care of. We have setup the challenge but now we also -need to provide the binary we obtained to the participant as part of the challenge. - -Beast provides a way to do this using a special file named `post-build.sh`. This script is run once finally -after all our environment setup is done, in this file you can read/write/modify the final environment once more -before finally coming to the challenge. Up until this point we have the binary with us, we can write this script -to copy the final binary to the publically available directory within the challenge named `public/`. - -This directory(`public/`) is exposed by beast using the static content provider. To make any file from within your challenge -publically accessible as part of the challenge you need to put that file in this directory rest is handled by beast itself. - -This is how our `post-bulid.sh` script looks like for this particular challenge - -```bash -#!/bin/bash - -set -euxo pipefail - -cp pwn public/ -``` - -And that's it, we have our challenge ready to be deployed by beast. - -## Deployment using beast - -We have our challenge ready with the required configuration. To deploy the challenge check out the Deployment flow [here](Deployment.md). - -## Note - -* To know more about the environment configuration possiblities with beast head out to [this section](ChallConfig.md). +The generated scaffold is a static challenge. For service, web, custom Dockerfile, Compose, and instanced variants, see [Challenge types](ChallTypes.md), [Challenge configuration](ChallConfig.md), and the repository's `_examples/` directory. diff --git a/docs/README.md b/docs/README.md index f879ad88..149ed6b8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,43 +1,19 @@ -# Beast Documentation +# Beast documentation -This directory contains documentation related to beast and will guide you through flow, architecture usage and gotchas of beast. +Beast builds and operates CTF challenges across local or remote Docker workers through an authenticated HTTPS API. -## Index +Start here: -* [Usage](Usage.md) -* [Setup](Setup.md) -* [Getting Started](GettingStarted.md) -* [Features](Features.md) -* [Architecture](Architecture.md) -* [Authentication Flow](APIAuth.md) -* [Challenge Types](ChallTypes.md) -* [Challenge Configuration](ChallConfig.md) -* [Sample Challenges](SampleChallenges.md) -* [Deployment](Deployment.md) +- [Setup](Setup.md): host, datastore, TLS, worker, and teardown requirements. +- [Usage](Usage.md): CLI and API operation. +- [Authentication](APIAuth.md): login, JWT, roles, and CORS. +- [Challenge configuration](ChallConfig.md): strict `beast.toml` schema and safety rules. +- [Challenge types](ChallTypes.md): static, service, web, bare/custom, and Compose models. +- [Architecture](Architecture.md): state, queues, workers, reconciliation, and trust boundaries. +- [Command reference](cmdref/beast.md): generated from the current binary. +The live server exposes generated Swagger documentation at `/api/docs/index.html` over HTTPS. -## Intro +## Operator warning -Beast is a service that runs on your host(may be a bare metal server or a cloud instance) and helps in the mangement of deployment, lifecycle and health check of CTF challenges. Beast is created to automate and ease the deployment procedure of challenges for a Jeopardy style CTF competition. As of now beast support the following type of challenges: - -* Service - A service hosted on beast container instance -* Web - Web based challenges for various languages including PHP, Python, Node.js etc. -* Static - Challenges with static files, this may include forensics challenges. -* Bare - Highly customisable challenges. -* Docker - Challenges which are provided with their own docker file. - -## Tech Stack - -Beast is written completely in Golang and comes with a clean REST API interface to trigger actions or interact with underlying functionalities. -The REST API server is implemented using `gin` go library and uses JWT as an authentication mechanism. Being written in go, Beast is compiled into -a single binary which can run on any linux distribution. - -Beast uses Docker as a container runtimes to run challenges in a sandboxed environment. Note that container does not provide a very strong isolation, but our host is safe as long as there is no 0-day in linux kernel itself. Even though container provide a security layer for the challenges, we follow some practices to harden those security measures. - -We use Swagger for automatic generation of API documentation and you can find the docs at `/api/docs/index.html` from beast server root. - -To save the state of the deployments and challenges beast uses SQLite as a database, all the information ranging from challenge deployment state to allocated ports and author information is stored in this database. This database is created automatically in the root of your beast configuration directory. - -### Note - -* To run challenges in a highly secure mode you can change the runtime for docker(by default it is runc) to gVisor(runsc) which provides a much better security layer in the containerized sandboxed environment. +Access to Docker is root-equivalent, and challenge build contexts contain executable organizer code. Run Beast on dedicated infrastructure, protect its configuration/keys, restrict management API reachability, and use verified TLS for every non-loopback dependency. diff --git a/docs/SampleChallenges.md b/docs/SampleChallenges.md index 3ffeacd2..23a1cf39 100644 --- a/docs/SampleChallenges.md +++ b/docs/SampleChallenges.md @@ -1,186 +1,20 @@ -# Sample Challenges +# Sample challenges -Below are some of the sample challenges wrapped in the format that beast can understand and deploy. +The maintained examples live in `_examples/`: -## Service Challenge +- `static-chall`: shared static service. +- `service` and `xinetd-service`: generated/custom xinetd services. +- `web-php` and `web-php-mysql`: generated web environments. +- `bare-docker`: custom Dockerfile challenge. +- `compose-type`: constrained multi-service Compose challenge. +- `instanced-service` and `instanced-compose`: per-user runtime instances. -These type of challenges need not only consist of binary challenges any executable can be hosted using service type -challenge which includes scripts too. Shell scripts, ruby scripts, python scripts can all be hosted using this type, make -sure you include the proper shebang at the top of script if you are hosting scripts -For binary service type challenges the author needs to build the binary first, this can be done using the setup scripts. - -```toml -[author] -name = "fristonio" -email = "deepeshpathak09@gmail.com" -ssh_key = "ssh-rsa AAAAB3NzaC1y" - -[challenge.metadata] -name = "sample" -flag = "CTF{sample_flag}" -type = "service" -points = 40 - -[challenge.env] -apt_deps = ["gcc", "socat"] -setup_scripts = ["setup.sh"] -service_path = "pwn" -ports = [10001] -``` - -Setup script +Validate an example before deployment: ```bash -set -e - -gcc -o pwn pwn_me.c - -exit 0 -``` - -## Docker Challenge - -To further improve the customizability/flexibility of challenge deployment a docker type challenge is provided. This can -be used in cases when the author knows how to create docker images, this brings to table the customization of the entire -container environment that beast can deploy. - -Any existing dockerized challenge can be easily ported to beast by simply creating the `beast.toml` configuration file with only -a few required fields. - -```toml -[author] -name = "fristonio" -email = "deepeshpathak09@gmail.com" -ssh_key = "ssh-rsa AAAAB3NzaC1y" - -[challenge.metadata] -name = "docker-type" -flag = "CTF{sample_flag}" -type = "docker" -points = 50 - -[challenge.env] -docker_context = "Dockerfile" -ports = [10002] +beast verify --local-directory "$PWD/_examples/service" ``` -```Dockerfile -FROM ubuntu:16.04 - -RUN apt-get update \ - && apt-get install -y gcc socat - -COPY script.sh /script.sh -COPY entrypoint.sh /entry.sh -COPY flag /flag - -EXPOSE 10002 - -RUN chmod +x /script.sh -RUN chmod +x /entry.sh - -CMD ["/entrypoint.sh"] -``` - -## Static challenges - -Some challenge don't need a complete environment to run, they just need to serve some static files to the user. This -happens a lot in case of forensics challenges. Beast optimizes the deployment of such challenges by creating just a single -environment(nginx based file server) for all such challenges. - -```toml -[author] -name = "fristonio" -email = "deepeshpathak09@gmail.com" -ssh_key = "ssh-rsa AAAAB3NzaC1y" - -[challenge.metadata] -name = "static-challenge" -flag = "CTF{sample_flag}" -type = "static" -points = 100 -``` - -In the above case all the files that are present in the challenge root are available for the player to download. - -### Note - -Every challenge in all the above provided types have a way to provide static files for the user to download, this can be -done using the `static_dir` in the challenge configuration. Every file in the provided `static_dir` directory will be provided -for download as a static asset. - -## Bare beast Challenge - -For these type challenges the key is the `run_cmd` configuration parameter in the `beast.toml` file. This -consist of the command to run for the container, any challenge can be deployed using this type which can -be customized by the using the corresponding `run_cmd`. - -For example a django challenge can be deployed by poviding `python manage.py` in the `run_cmd`. - -```toml -[author] -name = "fristonio" -email = "deepeshpathak09@gmail.com" -ssh_key = "ssh-rsa AAAAB3NzaC1y" - -[challenge.metadata] -name = "sample" -flag = "CTF{sample_flag}" -type = "bare" -hints = ["simple_hint_1", "simple_hint_2"] - -[challenge.env] -apt_deps = ["gcc", "socat"] -setup_scripts = ["setup.sh"] -run_cmd = "socat tcp-l:10003,fork,reuseaddr exec:./pwn" -ports = [10003] -``` - -Setup script - -```bash -set -e - -gcc -o pwn pwn_me.c - -exit 0 -``` - -## Web Challenges - -Beast can be used to deploy different type of challenges when in comes to web category. Currently beast supports the following type of web challenges - -* PHP -* Node -* Python - Django and Flask - -The type and environment of the challenge is decided by `type` field in the `challenge.metadata` section. The format of which is - -`web:::` - -### Sample PHP challenge - -```toml -[author] -name = "fristonio" -email = "deepeshpathak09@gmail.com" -ssh_key = "ssh-rsa AAAAB3NzaC1y" - -[challenge.metadata] -name = "web-php" -flag = "CTF{sample_flag}" -type = "web:php:7.1:cli" - -[challenge.env] -ports = [10002] -web_root = "challenge" -default_port = 10002 -``` - -The `web_root` is the base directory for the php server to locate the files. - -The type of challenge consist of the following format - `web:php::` - -#### Note +Deploying examples executes their setup scripts or Docker build instructions. Review them as code and use a disposable development worker. Example flags and passwords are intentionally public and must never be reused in a competition. -Make sure that you are installing all the mysql related dependencies in the `apt_deps` configuration parameter. As in the -above case `php*-mysql` +Compose examples use `${VARIABLE}:container-port` mappings. Beast supplies host ports from the selected worker's configured range; hard-coded host ports and arbitrary interpolation are not supported. diff --git a/docs/Setup.md b/docs/Setup.md index 40c506cc..38fdad12 100644 --- a/docs/Setup.md +++ b/docs/Setup.md @@ -1,222 +1,103 @@ -# Setting up Beast +# Setup -Beast compiles to a single Golang binary which can be used anywhere. +## Host assumptions -## Build +Beast is supported on Linux and requires Go 1.23+, Docker Engine, Git, Make, PostgreSQL, and Redis. Install them from trusted packages. `scripts/installenv.sh` verifies Go, Git, Make, and a usable Docker daemon; it deliberately does not install packages or alter system services. -To build beast from source follow the instruction below +Docker control is root-equivalent. Use a dedicated Beast account and host/VM, keep the management API private, and do not share that account with untrusted users. -- Make sure you have docker and golang installed in your system and have it in `$PATH` +## Build ```bash -$ mkdir -p $GOPATH/src/github.com/sdslabs/ - -$ git clone https://github.com/sdslabs/beastv4 $GOPATH/src/github.com/sdslabs/beastv4 - -$ cd $GOPATH/src/github.com/sdslabs/beastv4 - -# Build beast -$ make build - -# Build additional tooling required with beast. -$ make tools +git clone https://github.com/sdslabs/beastv4.git +cd beastv4 +./scripts/installenv.sh +make build +beast version ``` -Building beast with above method will copy the beast binary to `$GOBIN` so make sure that it is in your `$PATH` - -Run `beast version` to check if beast is build with the commit that you pulled the source code with. - -```bash -[fristonio] $ ~/golang/src/github.com/sdslabs/beastv4 documentation -❮❮ beast version - - ****************** Beast ****************** - - Version : 0.1 - Revision : 2b9cd25 - Branch : master - Build-User : fristonio@fristonio - Build-Date : 20190713-22:50:27 - Go-Version : 1.11 - - ******************************************* -``` - -## Configure - -All the beast related files lies in `$HOME/.beast` directory. Create this directory which will be used by beast. - -Create a configuration file for beast in the root of beast configuration directory(`$HOME/.beast`) named `config.toml`, an example configuration for the same is present in `/_examples/example.config.toml`. - -### Configuration file - -The structure of the configuration file used by beast is as follows - -```toml -# Authorized key file used by ssh daemon running on the host -# This is used for forwarding ssh connection to docker containers, the -# access to a container is only given to the author of the challenge. -authorized_keys_file = "/home/fristonio/.beast/beast_authorized_keys" - - -# Directory which will contain all the autogenerated scripts by beast -# These scripts are the heart to above authorized keys file. Each entry in authorized -# keys file as a corresponding script which is executed during an SSH attempt. -scripts_dir = "/home/fristonio/.beast/scripts" - - -# Base OS image that beast allows the challenges to use. -allowed_base_images = ["ubuntu:18.04", "ubuntu:16.04", "debian:jessie"] +Set `BEAST_OUTPUT=/absolute/path/beast` when a location other than `$(go env GOPATH)/bin/beast` is desired. +## PostgreSQL and Redis -# For authentication purposes beast uses JWT based authentication, this is the -# key used for encrypting the claims of a user. Keep this strong. -jwt_secret = "beast_jwt_secret_SUPER_STRONG_0x100010000100" +Create or select PostgreSQL and Redis services before initialization. Beast can provision its application database/user and Redis ACL user, but it needs administrator credentials during `beast init`. +For loopback-only development, PostgreSQL may use `sslmode = "disable"` and Redis may use `tls = false`. For any non-loopback address: -# To allow beast to send notification to a notification channel povide this webhook URL -# We are also working on implmeneting notification using IRC. -[[notification_webhooks]] +- PostgreSQL must use `sslmode = "verify-full"` and `sslrootcert` must identify a readable CA bundle. +- Redis must use `tls = true`; configure `ca_file` when the service CA is not in system roots and `server_name` when it differs from the host. -# The webhook URL of notification channel where notification should be sent -url = "" +Beast stores only its own keys under `beast:*` and creates a command-scoped Redis ACL. Do not reuse the configured application credentials as datastore administrator credentials. -# The service name to be used. It can be `discord` and `slack` -service_name = "discord" +## Initialize -# Status of webhook URL to be used. -# If it is false then notification will not be sent on this URL -active = true +The recommended interactive path is: +```bash +beast init +``` -# The frequency for any periodic event in beast, the value is provided in seconds. -# This is currently only used for health check periodic duration.s -ticker_frequency = 3000 - - -# Container default resource limits for each challenge, this can be -# Overridden by challenge configuration beast.toml file. -default_cpu_shares = 1024 -default_memory_limit = 1024 -default_pids_limit = 100 - - -# Configuration corresponding to the remote repository used by beast -# We use ssh authentication mechanism for interacting with git repository. -[[remote]] - -# URL of the remote git repository, this should be user@host: format -url = "git@github.com:sdslabs/hack-test.git" - -# Name of the remote -name = "hack-test" - -# Branch we are tracking the remote in beast. -branch = "master" - -# Path to private SSH key for interacting with the git repository. -ssh_key = "/home/fristonio/.beast/secrets/key.priv" - -# Status of remote git repository URL to be used -# If it is set to false then that remote git repository will not be used -active = true - -# The following fields are required only while hosting a competition on beast -# This section contains information about the competition to be hosted -# Structure of the sections with the acceptable fields are: - -# Required Fields +Initialization: -# Name of the competition -name = "" +1. Creates `$HOME/.beast` and private subdirectories with mode `0700`. +2. Creates a mode-`0600` configuration and prompts for datastore, worker, resource, and competition settings. +3. Generates a localhost-only development certificate when the default TLS files are both absent. +4. Checks Docker, provisions the Redis ACL and PostgreSQL database, runs schema migrations, and optionally creates an administrator. -# About the competition -about = "" +For a pre-generated configuration: -# Starting time of competition wrt time zone in `16:31:23 UTC: +05:30, 17th February 2021, Wednesday` format -starting_time = "" +```bash +./setup.sh +$EDITOR "$HOME/.beast/config.toml" +beast init +``` -# Ending time of competition wrt time zone in `16:31:23 UTC: +05:30, 17th February 2021, Wednesday` format -ending_time = "" +`setup.sh` creates unique JWT/PostgreSQL/Redis secrets and builds Beast. It does not install, start, or reconfigure external services. It never overwrites an existing configuration. -# Time zone for reference in `Asia/Calcutta: UTC +05:30` format -timezone = "" +For production, replace the generated localhost certificate with a certificate issued for the deployed hostname. The certificate may be public; the key must be a non-symlink regular file with mode `0600`. -# Optional fields +## Important configuration controls -# Prizes for the competition winners -prizes = "" +Use `_examples/example.config.toml` as the annotated reference. -# Absolute path of logo file. Default logo dir is in the "BEAST_GLOBAL_DIR/assets/" -logo_url = "" -``` +- `config.toml` must be a non-symlink regular file with mode `0600`. +- `jwt_secret` must be unique and at least 32 bytes. +- `allowed_origins` is an explicit CORS allowlist. Use HTTPS except for loopback development. +- Default CPU, memory, PID, and CPU-count values are also maximum per-challenge overrides. +- Every active worker needs a non-overlapping host `port_range` on that worker. +- Remote workers require a mode-`0600` SSH key and a trusted `known_hosts` file. Beast never accepts an unknown host key automatically. +- Active Git remotes accept SSH URLs only and require a mode-`0600` key. +- Active webhooks must be official HTTPS Slack or Discord webhook URLs. -Along with this configuration file we also need one more configuration file which is used by beast static content provider -and protects some routes for the same. +Remote SSH users need access to Docker on their worker. That permission is root-equivalent; use a dedicated account and key. Beast runs argument-quoted commands and stores remote staging data under the SSH user's `~/.beast` directory. -The file is located at `$HOME/.beast/.static.beast.htpasswd` and is generated using `htpasswd` utility. To generate this file -use the below command. +## Run ```bash -$ htpasswd -C 10 -c -B .static.beast.htpasswd -New Password: +beast run --health-probe ``` -### Configuration Directory Structure +The default listener is `https://0.0.0.0:5005`. Restrict it with host firewall/security-group rules or a trusted reverse proxy. API documentation is available at `https://:5005/api/docs/index.html`. -The configuration directory structure of beast(`$HOME/.beast`) look something as below: +Optional run controls include `--auto-deploy`, `--periodic-sync`, `--no-cache`, `--port`, and `--default-author-password-file`. The password file must be a non-symlink regular file with mode `0600` and contain 12–128 bytes. -``` -.beast/ -├── assets -│ └── logo.png -├── authorized_keys_file -├── beast.db -├── config.toml -├── hack-secrets -│   ├── id_rsa -│   └── id_rsa.pub -├── remote -│   └── hack-test -│   └── challenges -│   └── MIGHTY-PHP -│      ├── beast.toml -│      └── challenge -│     └── flag.php -├── scripts -│   └── 043a6aa3658c08c85d64321d986afbf69cb7ad345f16fe8aa0368ee6478f6e24 -├ -├── staging -│ └── MIGHTY-PHP -│    ├── beast.toml -│    ├── Dockerfile -│    ├── logs -│    │   └── MIGHTY-PHP.20190609173618.log -│    ├── MIGHTY-PHP.tar.gz -│    └── static -├── uploads -│   └── MIGHTY-GO -│      ├── beast.toml -│      └── challenge -│     └── flag.go -``` +## Static assets -### Configuring frontend for competition hosting +Build the pinned unprivileged Nginx image with: -Clone the [frontend repository](https://github.com/sdslabs/beast-frontend) and follow the setup instructions mentioned in its `README.md` file. +```bash +make requirements +``` -## Run +Deploy it through the authenticated management API. Beast mounts only each challenge's normalized static directory read-only and publishes the container on host port `8034`. Put a TLS reverse proxy in front of that port and configure `beast_static_url` to the public HTTPS origin. No htpasswd file is used. -Once the setup and configuration is done run the beast web server using the below command +## Stop and remove local state ```bash -beast run -v -p +./scripts/teardown.sh +./scripts/teardown.sh --purge-data ``` -Follow the API Authentication flow to obtain a Token, use that token further to make any REST API call to the beast server. -The whole swagger API documentation for the REST API can be found at the `http://localhost:5005/api/docs/index.html` - -## Note +Teardown obtains or verifies the controller lock before signaling a same-user Beast process. Normal shutdown preserves deployed challenges. `--purge-data` removes local `$BEAST_HOME` (default `$HOME/.beast`) only; PostgreSQL and Redis data are not removed. -- Make sure all the secrets/passwords you are using are strong enough. Also, make sure that the static content provider endpoint - is HTTPS protected. +Back up the PostgreSQL database and Redis state before destructive reset/restore commands. Those CLI commands require `--yes`. diff --git a/docs/Usage.md b/docs/Usage.md index 86ded92f..e06802ac 100644 --- a/docs/Usage.md +++ b/docs/Usage.md @@ -1,55 +1,60 @@ # Usage -## Installation - -Before provisioning beast on your host, make sure you have the following dependencies installed. - -* [Docker for Linux](https://docs.docker.com/install/linux/docker-ce/ubuntu/) -* [GoLang for Linux](https://golang.org/doc/install#tarball) -* [git](https://git-scm.com/) - -You can either build beast from source or download a latest realease binary from Github Relaeases. +All API traffic uses HTTPS. Examples below trust the development certificate explicitly: ```bash -$ export GO111MODULES=on +export BEAST_URL=https://localhost:5005 +export BEAST_CA="$HOME/.beast/secrets/tls.crt" +``` -$ git clone git@github.com:sdslabs/beastv4.git +## Authenticate -$ cd beastv4 && make build ->>> Building Beast +```bash +beast getauth --host "$BEAST_URL" --ca-file "$BEAST_CA" --username ``` -This should build beast from source in `$GOPATH/bin/beast`, you can then use this binary to run beast API server. The `-n` -flag tells beast to not use the authorization middleware. +Or call the login endpoint and extract the returned token: ```bash -$ beast run -v -n +curl --cacert "$BEAST_CA" --request POST "$BEAST_URL/auth/login" \ + --data-urlencode 'username=' \ + --data-urlencode 'password=' ``` -To interact with beast API server you can look at the swagger API documentation hosted on beast itself. Navigate to http://localhost:5005/api/docs/index.html to get a detail of available endpoints. +Send `Authorization: Bearer ` on protected routes. Never place passwords or tokens in URLs, shell history, source files, or logs. -To be able to interact with the REST API interface you should be authorized by beast, go to [Authentication Section](/APIAuth.md) to know about the flow of authentication. +## CLI challenge operations -## Examples +```bash +beast challenge show --all +beast challenge deploy +beast challenge undeploy +beast challenge redeploy +beast challenge purge +``` -Some examples of API action triggers are given below +Bulk operations accept `--all` or `--tag`. Controller-local deployment accepts `beast challenge deploy --local-directory /absolute/path`; validate it first with `beast verify --local-directory /absolute/path`. The HTTP equivalents for bulk, scheduling, static-service, and controller-local path operations are administrator-only. Authors/maintainers may operate only challenges they own. -```bash -# Reloading a configuration change in beast global config -$ curl -X PATCH localhost:5005/api/config/reload -{"message":"CONFIG RELOAD SUCCESSFUL"} +Worker failures are returned by the CLI after queued work completes. A successful enqueue is not reported as a successful deployment. -# Deploying a challenge named my-challenge using API. -$ curl -X POST --data "action=deploy&name=my-challenge" localhost:5005/api/manage/challenge/ -{"message":"Deploy for challenge simple-web has been triggered, check stats"} +## API examples -# Purging the deployed challenge completely. -$ curl -X POST --data "action=purge&name=my-challenge" localhost:5005/api/manage/challenge/ -{"message":"Your action purge on challenge simple-web was successful"} +```bash +curl --cacert "$BEAST_CA" \ + --header "Authorization: Bearer $BEAST_TOKEN" \ + "$BEAST_URL/api/status/all" + +curl --cacert "$BEAST_CA" \ + --header "Authorization: Bearer $BEAST_TOKEN" \ + --request POST "$BEAST_URL/api/manage/challenge/" \ + --data-urlencode 'action=deploy' \ + --data-urlencode 'name=my-challenge' ``` -For more examples and available API routes go to Swagger API documentation. +Use Swagger at `$BEAST_URL/api/docs/index.html` for the current route and payload contract. Management endpoints require author/maintainer or administrator roles; remote, configuration, user-control, and static-service operations require an administrator. + +## Maintenance -## Note +Database and cache backup/reset/restore commands initialize the same validated runtime configuration as the server. Reset and restore are destructive and require `--yes`. Backups are explicit operations; health checks and shutdown do not perform synchronous backups. -* You can even run beast on your local environment and still be able to configure and manage deployments, for this you will need a secure tunnel to reach your docker daemon, which will be used for container lifecycle management on the actual host. +Controller shutdown stops its workers, scheduler, subscriptions, and datastore connections while preserving deployed workloads. On restart, Beast reconciles runtime state and expired instances. diff --git a/docs/cmdref/beast.md b/docs/cmdref/beast.md index aef4f7a7..a8db8350 100644 --- a/docs/cmdref/beast.md +++ b/docs/cmdref/beast.md @@ -20,22 +20,28 @@ beast [flags] ``` -h, --help help for beast - -n, --noauth Skip Authorization -v, --verbose Print extra information in stdout1 ``` ### SEE ALSO +* [beast backup-cache](beast_backup-cache.md) - Backs up the configured Redis database +* [beast backup-database](beast_backup-database.md) - Backs up the configured PostgreSQL database +* [beast chall-details](beast_chall-details.md) - Lists all challenge details * [beast challenge](beast_challenge.md) - Performs action to the challs * [beast cmdref](beast_cmdref.md) - Generate beast command reference -* [beast create-author](beast_create-author.md) - Creates new author -* [beast disable-author-ssh](beast_disable-author-ssh.md) - Disables current authors to ssh into the containers -* [beast getauth](beast_getauth.md) - Gets Auth token from beast server +* [beast config](beast_config.md) - Run interactive beast configuration setup +* [beast create-admin](beast_create-admin.md) - Creates a new admin +* [beast create-author](beast_create-author.md) - Creates a new author +* [beast getauth](beast_getauth.md) - Gets an authentication token from the Beast server * [beast health-probe](beast_health-probe.md) - Run Health Probe * [beast init](beast_init.md) - Run Beast initial setup bootsetps. * [beast logs](beast_logs.md) - Provides live logs of a container +* [beast new](beast_new.md) - Generate a challenge configuration and public directory +* [beast reset-cache](beast_reset-cache.md) - Backs up and resets the configured Redis database +* [beast reset-database](beast_reset-database.md) - Backs up and resets the configured PostgreSQL database +* [beast restore-cache](beast_restore-cache.md) - Restores the configured Redis database from a backup +* [beast restore-database](beast_restore-database.md) - Restores the configured PostgreSQL database from a backup * [beast run](beast_run.md) - Run Beast API server * [beast verify](beast_verify.md) - Verifies challenge config * [beast version](beast_version.md) - Displays the version of the current build of beast - -###### Auto generated by spf13/cobra on 5-Aug-2019 diff --git a/docs/cmdref/beast_backup-cache.md b/docs/cmdref/beast_backup-cache.md new file mode 100644 index 00000000..19df595a --- /dev/null +++ b/docs/cmdref/beast_backup-cache.md @@ -0,0 +1,27 @@ +## beast backup-cache + +Backs up the configured Redis database + +### Synopsis + +Backs up the configured Redis database + +``` +beast backup-cache [flags] +``` + +### Options + +``` + -h, --help help for backup-cache +``` + +### Options inherited from parent commands + +``` + -v, --verbose Print extra information in stdout1 +``` + +### SEE ALSO + +* [beast](beast.md) - Beast is an deployment and management tool for CTF challenges. diff --git a/docs/cmdref/beast_backup-database.md b/docs/cmdref/beast_backup-database.md new file mode 100644 index 00000000..93e7b6c4 --- /dev/null +++ b/docs/cmdref/beast_backup-database.md @@ -0,0 +1,27 @@ +## beast backup-database + +Backs up the configured PostgreSQL database + +### Synopsis + +Backs up the configured PostgreSQL database + +``` +beast backup-database [flags] +``` + +### Options + +``` + -h, --help help for backup-database +``` + +### Options inherited from parent commands + +``` + -v, --verbose Print extra information in stdout1 +``` + +### SEE ALSO + +* [beast](beast.md) - Beast is an deployment and management tool for CTF challenges. diff --git a/docs/cmdref/beast_chall-details.md b/docs/cmdref/beast_chall-details.md new file mode 100644 index 00000000..42ba4360 --- /dev/null +++ b/docs/cmdref/beast_chall-details.md @@ -0,0 +1,29 @@ +## beast chall-details + +Lists all challenge details + +### Synopsis + +Lists all challenge details | Flags available : --status , --tags. Status flag can take arguments : deployed / undeployed / queued. Tags flag can take multiple arguments seperated with ',' : (Ex : --tags=pwn,image,docker). Details are shown for challenges that have specified status and one of the specified tags. + +``` +beast chall-details [flags] +``` + +### Options + +``` + -h, --help help for chall-details + -s, --status string Filter by status : deployed / undeployed / queued (default "all") + -t, --tags string Filter by tagname : pwn / web / image / docker +``` + +### Options inherited from parent commands + +``` + -v, --verbose Print extra information in stdout1 +``` + +### SEE ALSO + +* [beast](beast.md) - Beast is an deployment and management tool for CTF challenges. diff --git a/docs/cmdref/beast_challenge.md b/docs/cmdref/beast_challenge.md index 13841708..d7ac28f3 100644 --- a/docs/cmdref/beast_challenge.md +++ b/docs/cmdref/beast_challenge.md @@ -17,18 +17,16 @@ beast challenge action [challname] [-atld] [flags] -d, --delete-entry Deletes db entry related to this challenge -h, --help help for challenge -l, --local-directory string Deploys challenge from local directory + -c, --no-cache Build image of challenge without using cache -t, --tag string Performs action to the tag provided ``` ### Options inherited from parent commands ``` - -n, --noauth Skip Authorization -v, --verbose Print extra information in stdout1 ``` ### SEE ALSO * [beast](beast.md) - Beast is an deployment and management tool for CTF challenges. - -###### Auto generated by spf13/cobra on 5-Aug-2019 diff --git a/docs/cmdref/beast_cmdref.md b/docs/cmdref/beast_cmdref.md index 9734ed0c..29c95194 100644 --- a/docs/cmdref/beast_cmdref.md +++ b/docs/cmdref/beast_cmdref.md @@ -7,7 +7,7 @@ Generate beast command reference Generate beast command reference ``` -beast cmdref [-d] [flags] +beast cmdref [-r] [flags] ``` ### Options @@ -20,12 +20,9 @@ beast cmdref [-d] [flags] ### Options inherited from parent commands ``` - -n, --noauth Skip Authorization -v, --verbose Print extra information in stdout1 ``` ### SEE ALSO * [beast](beast.md) - Beast is an deployment and management tool for CTF challenges. - -###### Auto generated by spf13/cobra on 5-Aug-2019 diff --git a/docs/cmdref/beast_config.md b/docs/cmdref/beast_config.md new file mode 100644 index 00000000..bdc60a50 --- /dev/null +++ b/docs/cmdref/beast_config.md @@ -0,0 +1,27 @@ +## beast config + +Run interactive beast configuration setup + +### Synopsis + +Creates the global Beast config file while prompting the user interactively whenever needed. + +``` +beast config [flags] +``` + +### Options + +``` + -h, --help help for config +``` + +### Options inherited from parent commands + +``` + -v, --verbose Print extra information in stdout1 +``` + +### SEE ALSO + +* [beast](beast.md) - Beast is an deployment and management tool for CTF challenges. diff --git a/docs/cmdref/beast_create-admin.md b/docs/cmdref/beast_create-admin.md new file mode 100644 index 00000000..193fe794 --- /dev/null +++ b/docs/cmdref/beast_create-admin.md @@ -0,0 +1,30 @@ +## beast create-admin + +Creates a new admin + +### Synopsis + +Creates a new admin + +``` +beast create-admin [flags] +``` + +### Options + +``` + --email string Email of the new admin + -h, --help help for create-admin + --name string Name of the new admin + --username string Username of the new admin +``` + +### Options inherited from parent commands + +``` + -v, --verbose Print extra information in stdout1 +``` + +### SEE ALSO + +* [beast](beast.md) - Beast is an deployment and management tool for CTF challenges. diff --git a/docs/cmdref/beast_create-author.md b/docs/cmdref/beast_create-author.md index ad9000d9..4acaaf5d 100644 --- a/docs/cmdref/beast_create-author.md +++ b/docs/cmdref/beast_create-author.md @@ -1,10 +1,10 @@ ## beast create-author -Creates new author +Creates a new author ### Synopsis -Creates new author using command line arguments +Creates a new author ``` beast create-author [flags] @@ -13,21 +13,18 @@ beast create-author [flags] ### Options ``` - --email string Email of the new author - -h, --help help for create-author - --name string Name of the new author - --publickey string Public key file representing new author + --email string Email of the new author + -h, --help help for create-author + --name string Name of the new author + --username string Username of the new author ``` ### Options inherited from parent commands ``` - -n, --noauth Skip Authorization -v, --verbose Print extra information in stdout1 ``` ### SEE ALSO * [beast](beast.md) - Beast is an deployment and management tool for CTF challenges. - -###### Auto generated by spf13/cobra on 5-Aug-2019 diff --git a/docs/cmdref/beast_disable-author-ssh.md b/docs/cmdref/beast_disable-author-ssh.md deleted file mode 100644 index a9a3b1b9..00000000 --- a/docs/cmdref/beast_disable-author-ssh.md +++ /dev/null @@ -1,30 +0,0 @@ -## beast disable-author-ssh - -Disables current authors to ssh into the containers - -### Synopsis - -Disables current authors to ssh into the containers - -``` -beast disable-author-ssh [flags] -``` - -### Options - -``` - -h, --help help for disable-author-ssh -``` - -### Options inherited from parent commands - -``` - -n, --noauth Skip Authorization - -v, --verbose Print extra information in stdout1 -``` - -### SEE ALSO - -* [beast](beast.md) - Beast is an deployment and management tool for CTF challenges. - -###### Auto generated by spf13/cobra on 5-Aug-2019 diff --git a/docs/cmdref/beast_getauth.md b/docs/cmdref/beast_getauth.md index 516fffb5..ca16c47a 100644 --- a/docs/cmdref/beast_getauth.md +++ b/docs/cmdref/beast_getauth.md @@ -1,10 +1,10 @@ ## beast getauth -Gets Auth token from beast server +Gets an authentication token from the Beast server ### Synopsis -Gets Auth Token from the beast server by completing the challenge from the server +Gets an authentication token from the Beast server ``` beast getauth [flags] @@ -13,21 +13,18 @@ beast getauth [flags] ### Options ``` + --ca-file string CA certificate used to verify the Beast server -h, --help help for getauth - -H, --host string Hostname or IP along with port where beast is hosted (default "http://localhost:5005/") - -i, --identity string Private File location + -H, --host string HTTPS URL where Beast is hosted (default "https://localhost:5005/") -u, --username string Username ``` ### Options inherited from parent commands ``` - -n, --noauth Skip Authorization -v, --verbose Print extra information in stdout1 ``` ### SEE ALSO * [beast](beast.md) - Beast is an deployment and management tool for CTF challenges. - -###### Auto generated by spf13/cobra on 5-Aug-2019 diff --git a/docs/cmdref/beast_health-probe.md b/docs/cmdref/beast_health-probe.md index e0997165..18c484b3 100644 --- a/docs/cmdref/beast_health-probe.md +++ b/docs/cmdref/beast_health-probe.md @@ -19,12 +19,9 @@ beast health-probe [flags] ### Options inherited from parent commands ``` - -n, --noauth Skip Authorization -v, --verbose Print extra information in stdout1 ``` ### SEE ALSO * [beast](beast.md) - Beast is an deployment and management tool for CTF challenges. - -###### Auto generated by spf13/cobra on 5-Aug-2019 diff --git a/docs/cmdref/beast_init.md b/docs/cmdref/beast_init.md index 8f34d84f..1ebd5f96 100644 --- a/docs/cmdref/beast_init.md +++ b/docs/cmdref/beast_init.md @@ -4,7 +4,7 @@ Run Beast initial setup bootsetps. ### Synopsis -Initializes beast by setting up beast directory, checking for permission. It also configures the logger and local SQLite database to be used by beast +Initializes Beast directories, configuration, TLS, Redis ACLs, PostgreSQL schema, and an optional administrator account. ``` beast init [flags] @@ -19,12 +19,9 @@ beast init [flags] ### Options inherited from parent commands ``` - -n, --noauth Skip Authorization -v, --verbose Print extra information in stdout1 ``` ### SEE ALSO * [beast](beast.md) - Beast is an deployment and management tool for CTF challenges. - -###### Auto generated by spf13/cobra on 5-Aug-2019 diff --git a/docs/cmdref/beast_logs.md b/docs/cmdref/beast_logs.md index e1ad0beb..4375751f 100644 --- a/docs/cmdref/beast_logs.md +++ b/docs/cmdref/beast_logs.md @@ -19,12 +19,9 @@ beast logs CHALLNAME [flags] ### Options inherited from parent commands ``` - -n, --noauth Skip Authorization -v, --verbose Print extra information in stdout1 ``` ### SEE ALSO * [beast](beast.md) - Beast is an deployment and management tool for CTF challenges. - -###### Auto generated by spf13/cobra on 5-Aug-2019 diff --git a/docs/cmdref/beast_new.md b/docs/cmdref/beast_new.md new file mode 100644 index 00000000..a3bf8c9a --- /dev/null +++ b/docs/cmdref/beast_new.md @@ -0,0 +1,27 @@ +## beast new + +Generate a challenge configuration and public directory + +### Synopsis + +Generate a challenge configuration and public directory + +``` +beast new [flags] +``` + +### Options + +``` + -h, --help help for new +``` + +### Options inherited from parent commands + +``` + -v, --verbose Print extra information in stdout1 +``` + +### SEE ALSO + +* [beast](beast.md) - Beast is an deployment and management tool for CTF challenges. diff --git a/docs/cmdref/beast_reset-cache.md b/docs/cmdref/beast_reset-cache.md new file mode 100644 index 00000000..accd4016 --- /dev/null +++ b/docs/cmdref/beast_reset-cache.md @@ -0,0 +1,28 @@ +## beast reset-cache + +Backs up and resets the configured Redis database + +### Synopsis + +Backs up and resets the configured Redis database + +``` +beast reset-cache [flags] +``` + +### Options + +``` + -h, --help help for reset-cache + --yes Confirm destructive cache reset +``` + +### Options inherited from parent commands + +``` + -v, --verbose Print extra information in stdout1 +``` + +### SEE ALSO + +* [beast](beast.md) - Beast is an deployment and management tool for CTF challenges. diff --git a/docs/cmdref/beast_reset-database.md b/docs/cmdref/beast_reset-database.md new file mode 100644 index 00000000..e80c92dd --- /dev/null +++ b/docs/cmdref/beast_reset-database.md @@ -0,0 +1,28 @@ +## beast reset-database + +Backs up and resets the configured PostgreSQL database + +### Synopsis + +Backs up and resets the configured PostgreSQL database + +``` +beast reset-database [flags] +``` + +### Options + +``` + -h, --help help for reset-database + --yes Confirm destructive database reset +``` + +### Options inherited from parent commands + +``` + -v, --verbose Print extra information in stdout1 +``` + +### SEE ALSO + +* [beast](beast.md) - Beast is an deployment and management tool for CTF challenges. diff --git a/docs/cmdref/beast_restore-cache.md b/docs/cmdref/beast_restore-cache.md new file mode 100644 index 00000000..e111dbb0 --- /dev/null +++ b/docs/cmdref/beast_restore-cache.md @@ -0,0 +1,29 @@ +## beast restore-cache + +Restores the configured Redis database from a backup + +### Synopsis + +Restores the configured Redis database from a backup + +``` +beast restore-cache [flags] +``` + +### Options + +``` + -h, --help help for restore-cache + -r, --restore-file string Restore file to be used for restoration. + --yes Confirm destructive cache restore +``` + +### Options inherited from parent commands + +``` + -v, --verbose Print extra information in stdout1 +``` + +### SEE ALSO + +* [beast](beast.md) - Beast is an deployment and management tool for CTF challenges. diff --git a/docs/cmdref/beast_restore-database.md b/docs/cmdref/beast_restore-database.md new file mode 100644 index 00000000..4f3238ca --- /dev/null +++ b/docs/cmdref/beast_restore-database.md @@ -0,0 +1,29 @@ +## beast restore-database + +Restores the configured PostgreSQL database from a backup + +### Synopsis + +Restores the configured PostgreSQL database from a backup + +``` +beast restore-database [flags] +``` + +### Options + +``` + -h, --help help for restore-database + -r, --restore-file string Backup file to be used for restoration. + --yes Confirm destructive database restore +``` + +### Options inherited from parent commands + +``` + -v, --verbose Print extra information in stdout1 +``` + +### SEE ALSO + +* [beast](beast.md) - Beast is an deployment and management tool for CTF challenges. diff --git a/docs/cmdref/beast_run.md b/docs/cmdref/beast_run.md index 7003c45e..bc98a4ef 100644 --- a/docs/cmdref/beast_run.md +++ b/docs/cmdref/beast_run.md @@ -13,21 +13,21 @@ beast run [flags] ### Options ``` - -k, --health-probe Run health check service for beast deployed challenges - -h, --help help for run - -s, --periodic-sync Periodically sync remote with beast. - -p, --port string Port to run the beast server on. + -a, --auto-deploy Auto deploy all challenges from remote on server start. + --default-author-password-file string 0600 file containing the password used to create missing authors + -k, --health-probe Run health check service for beast deployed challenges + -h, --help help for run + -c, --no-cache Build image of challenge without using cache + -s, --periodic-sync Periodically sync remote with beast and auto update challenges. + -p, --port string Port to run the beast server on. ``` ### Options inherited from parent commands ``` - -n, --noauth Skip Authorization -v, --verbose Print extra information in stdout1 ``` ### SEE ALSO * [beast](beast.md) - Beast is an deployment and management tool for CTF challenges. - -###### Auto generated by spf13/cobra on 5-Aug-2019 diff --git a/docs/cmdref/beast_verify.md b/docs/cmdref/beast_verify.md index 5feec9ac..82529833 100644 --- a/docs/cmdref/beast_verify.md +++ b/docs/cmdref/beast_verify.md @@ -7,24 +7,22 @@ Verifies challenge config Verifies challenge config ``` -beast verify challenge-name [flags] +beast verify [challenge-name] [flags] ``` ### Options ``` - -h, --help help for verify + -h, --help help for verify + -l, --local-directory string Validate a challenge from a local directory ``` ### Options inherited from parent commands ``` - -n, --noauth Skip Authorization -v, --verbose Print extra information in stdout1 ``` ### SEE ALSO * [beast](beast.md) - Beast is an deployment and management tool for CTF challenges. - -###### Auto generated by spf13/cobra on 5-Aug-2019 diff --git a/docs/cmdref/beast_version.md b/docs/cmdref/beast_version.md index e5047b30..1607aa84 100644 --- a/docs/cmdref/beast_version.md +++ b/docs/cmdref/beast_version.md @@ -20,12 +20,9 @@ beast version [flags] ### Options inherited from parent commands ``` - -n, --noauth Skip Authorization -v, --verbose Print extra information in stdout1 ``` ### SEE ALSO * [beast](beast.md) - Beast is an deployment and management tool for CTF challenges. - -###### Auto generated by spf13/cobra on 5-Aug-2019 diff --git a/mkdocs.yml b/mkdocs.yml index 0b0b5227..01db8eaf 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -2,11 +2,18 @@ site_name: Beast nav: - Home: README.md - - Usage: Usage.md - Setup: Setup.md - - Documentation: Documentation.md - - Samples: SampleChallenges.md - - APIDocs: /apiDocs - - CmdRef: cmdref/beast.md + - Getting started: GettingStarted.md + - Usage: Usage.md + - Authentication: APIAuth.md + - Challenges: + - Configuration: ChallConfig.md + - Types: ChallTypes.md + - Samples: SampleChallenges.md + - Architecture: Architecture.md + - Features: Features.md + - Deployment: Deployment.md + - Contributing: Contribution.md + - Command reference: cmdref/beast.md theme: mkdocs