commit 134351603c0ccf2a61ea29b959b5205344fc7ddb Author: lilleman Date: Sat Feb 3 04:17:32 2024 +0100 Initial commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b4430a6 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +API_KEYS=xxx,yyy +LOG_LEVEL=Verbose +LOG_LOCATION=Europe/Stockholm +PORT=4200 +RUN_CMD_AS_GID=1000 +RUN_CMD_AS_UID=1000 +WORK_DIR=/tmp/runner \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6ddbfc6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.env +bin +runner \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..69099ce --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +# Runner of commands in payload on a host + +Example of sending hello world to runner at 127.0.0.1:8072 + +`curl -H "Authorization: xxx" -d '{"commands":["echo \"Hello world\""]}' 127.0.0.1:8072 + +* WORK_DIR will be WIPED on every run!!!! diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7b99092 --- /dev/null +++ b/go.mod @@ -0,0 +1,9 @@ +module gitea.larvit.se/pwrpln/runner + +go 1.21.6 + +require gitea.larvit.se/pwrpln/go_log v0.3.0 + +require github.com/joho/godotenv v1.5.1 + +require github.com/google/uuid v1.6.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..2f76e06 --- /dev/null +++ b/go.sum @@ -0,0 +1,6 @@ +gitea.larvit.se/pwrpln/go_log v0.3.0 h1:zxCQ4vPU8gqsYT35DIrGm1qntd88oDbzd7SvzAmNi1s= +gitea.larvit.se/pwrpln/go_log v0.3.0/go.mod h1:CVZH9ge+rbQyhc+HYSI+B1A3j54wnQxzAcFgFMqsfLw= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= diff --git a/main.go b/main.go new file mode 100644 index 0000000..d69027c --- /dev/null +++ b/main.go @@ -0,0 +1,375 @@ +package main + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "slices" + "strconv" + "strings" + "sync" + "syscall" + "time" + + "gitea.larvit.se/pwrpln/go_log" + "github.com/google/uuid" + "github.com/joho/godotenv" +) + +type Payload struct { + Commands []string `json:"commands"` +} + +type User struct { + HomeDir string + Username string +} + +func cleanEnv(log go_log.Log, workDir string, uid int, gid int) (string, error) { + response := "" + + log.Debug("Creating working directory if it does not exist", "workDir", workDir) + response += "Creating working directory if it does not exist\n" + err := os.MkdirAll(workDir, os.ModePerm) + if err != nil { + log.Error("Could not create working directory", err, err.Error()) + return response, err + } + + log.Debug("Resetting owner on working directory") + response += "Resetting owner on working directory" + err = os.Chown(workDir, uid, gid) + if err != nil { + log.Error("Could not set owner on working directory", err, err.Error()) + return response, err + } + + log.Debug("Cleaning up previous files and/or folders in working directory", "workDir", workDir) + response += "Cleaning up previous files and/or folders in working directory\n" + err = rmDirectoryContent(log, workDir) + if err != nil { + log.Error("Could not clear workDir") + return response, err + } + + // Remove all docker containeres + _, _, err = runCmd(log, workDir, "docker ps -aq | xargs -r docker rm -f", uid, gid) + response += "Removing all pre-existing docker containers\n" + if err != nil { + log.Error("Could not remove all containers", "err", err.Error()) + return response, err + } + + // Remove all docker networks + _, _, err = runCmd(log, workDir, "docker network prune --force", uid, gid) + response += "Removing all pre-existing docker networks\n" + if err != nil { + log.Error("Could not remove all docker networks", "err", err.Error()) + return response, err + } + + // Remove all docker volumes + _, _, err = runCmd(log, workDir, "docker volume prune -a -f", uid, gid) + response += "Removing all pre-existing docker volumes\n" + if err != nil { + log.Error("Could not remove all docker volumes", "err", err.Error()) + return response, err + } + + // Log out from all docker registries + _, _, err = runCmd(log, workDir, "for url in $(cat ~/.docker/config.json | jq -r '.auths | keys | .[]'); do docker logout $url; done", uid, gid) + response += "Logging out from all configured docker registries\n" + if err != nil { + log.Error("Could not log out from all configured docker registries", "err", err.Error()) + return response, err + } + + return response, nil +} + +func getUserByUid(log go_log.Log, uid int) (User, error) { + // Get uids home directory + passwdFile, err := os.Open("/etc/passwd") + if err != nil { + log.Error("Could not read /etc/passwd", "err", err.Error()) + return User{}, err + } + defer passwdFile.Close() + + // Read file line by line + scanner := bufio.NewScanner(passwdFile) + for scanner.Scan() { + parts := strings.Split(scanner.Text(), ":") + if parts[2] == strconv.Itoa(uid) { + return User{ + HomeDir: parts[5], + Username: parts[0], + }, nil + } + } + + err = scanner.Err() + if err != nil { + log.Error("Could not scan /etc/passwd", "err", err.Error()) + return User{}, err + } + + log.Error("Could not find users home directory in /etc/passwd", "uid", uid) + err = errors.New("could not find users home directory in /etc/passwd") + return User{}, err +} + +func rmDirectoryContent(log go_log.Log, dir string) error { + d, err := os.Open(dir) + if err != nil { + log.Error("Could not open directory", "dir", dir, "err", err.Error()) + return err + } + defer d.Close() + names, err := d.Readdirnames(-1) + if err != nil { + log.Error("Could not list names in dir", "dir", dir, "err", err.Error()) + return err + } + for _, name := range names { + err = os.RemoveAll(filepath.Join(dir, name)) + if err != nil { + log.Error("Could not remove path", "path", filepath.Join(dir, name), "err", err.Error()) + return err + } + } + return nil +} + +func runCmd(log go_log.Log, workDir string, cmdStr string, uid int, gid int) (int, string, error) { + response := "" + + user, err := getUserByUid(log, uid) + if err != nil { + return 1, response, err + } + + // Create the command + cmd := exec.Command("/bin/sh", "-c", cmdStr) + cmd.Dir = workDir + cmd.SysProcAttr = &syscall.SysProcAttr{} + cmd.SysProcAttr.Credential = &syscall.Credential{Uid: uint32(uid), Gid: uint32(gid)} + + cmd.Env = append(cmd.Env, "USER="+user.Username) + cmd.Env = append(cmd.Env, "HOME="+user.HomeDir) + + // Create a pipes to capture the command's output + stdoutPipe, err := cmd.StdoutPipe() + if err != nil { + log.Error("Could not create stdout pipe from command", "err", err.Error()) + return 1, response, err + } + stderrPipe, err := cmd.StderrPipe() + if err != nil { + log.Error("Could not create stderr pipe from command", "err", err.Error()) + return 1, response, err + } + + // Start the command + err = cmd.Start() + if err != nil { + log.Error("Could not start the command", "err", err.Error()) + return 1, response, err + } + + defer stdoutPipe.Close() + defer stderrPipe.Close() + + var wg sync.WaitGroup + + log.Debug("Running command", "cmdStr", cmdStr) + + wg.Add(1) + go func() { + buf := make([]byte, 1024) + for { + n, err := stdoutPipe.Read(buf) + if err == io.EOF { + break + } + if err != nil { + log.Error("Error reading stdout from command", "err", err.Error()) + break + } + log.Debug("runCmd() stdOut", "buf", string(buf[:n])) + response += "stdOut: " + string(buf[:n]) + } + wg.Done() + }() + + wg.Add(1) + go func() { + buf := make([]byte, 1024) + for { + n, err := stderrPipe.Read(buf) + if err == io.EOF { + break + } + if err != nil { + log.Error("Error reading stderr from command", "err", err.Error()) + break + } + log.Debug("runCmd() stdErr", "buf", string(buf[:n])) + response += "stdErr: " + string(buf[:n]) + } + wg.Done() + }() + + wg.Wait() + + err = cmd.Wait() + if err != nil { + exitErr, ok := err.(*exec.ExitError) + if !ok { + log.Error("Fatal error on cmd.Wait()", "err", err.Error()) + return 1, response, err + } + + return exitErr.ExitCode(), response, nil + } + + return 0, response, nil +} + +func setRunnerFree(runnerActive *bool) { + *runnerActive = false +} + +func main() { + runnerActive := false + + // Load ENV from .env file + err := godotenv.Load() + if err != nil { + tmpLog := go_log.GetLog() + tmpLog.Info("Failed to load .env file!") + } + + acceptedApiKeys := strings.Split(os.Getenv("API_KEYS"), ",") + httpPort := os.Getenv("PORT") + logLevel := os.Getenv("LOG_LEVEL") + logLocation := os.Getenv("LOG_LOCATION") + workDir := os.Getenv("WORK_DIR") + + // Setting up logger + mainLog := go_log.GetLog() + mainLog.MinLogLvl = go_log.LogLvlFromStr(logLevel) + if mainLog.MinLogLvl == 0 { + mainLog.MinLogLvl = 3 // Fall back to Info log level, so we don't miss important errors + } + loc, err := time.LoadLocation(logLocation) + if err != nil { + panic(err) + } + mainLog.TimeLocation = loc + + uid, err := strconv.Atoi(os.Getenv("RUN_CMD_AS_UID")) + if err != nil { + mainLog.Error("Could not read RUN_CMD_AS_UID as integer", "RUN_CMD_AS_UID", os.Getenv("RUN_CMD_AS_UID"), "err", err.Error()) + os.Exit(1) + } + + gid, err := strconv.Atoi(os.Getenv("RUN_CMD_AS_GID")) + if err != nil { + mainLog.Error("Could not read RUN_CMD_AS_GID as integer", "RUN_CMD_AS_GID", os.Getenv("RUN_CMD_AS_GID"), "err", err.Error()) + os.Exit(1) + } + + mainLog.Info( + "Configuration is determined", + "API_KEYS", len(acceptedApiKeys), + "LOG_LEVEL", logLevel, + "LOG_LOCATION", logLocation, + "WORK_DIR", workDir, + "PORT", httpPort, + "RUN_CMD_AS_GID", gid, + "RUN_CMD_AS_UID", uid, + ) + + http.HandleFunc("/", func(res http.ResponseWriter, req *http.Request) { + reqId := uuid.NewString() + log := mainLog // Make copy of main log to use in each request + log.Context = append(log.Context, "reqId", reqId) + defer log.Verbose("Run complete") + response := "" + providedApiKey := req.Header.Get("Authorization") + + if !slices.Contains(acceptedApiKeys, providedApiKey) { + log.Debug("Invalid API key provided") + res.WriteHeader(401) + res.Write([]byte("Unauthorized")) + return + } + + log.Verbose("Making a run") + + for runnerActive { + log.Debug("Another runner is running, waiting") + time.Sleep(1 * time.Second) + } + runnerActive = true + defer setRunnerFree(&runnerActive) + + // Clean up environment + cleanEnvRes, err := cleanEnv(log, workDir, uid, gid) + if err != nil { + res.WriteHeader(500) + res.Write([]byte("Internal Server Error")) + return + } + response += cleanEnvRes + + decoder := json.NewDecoder(req.Body) + var payload Payload + err = decoder.Decode(&payload) + if err != nil { + log.Verbose("Invalid JSON payload", "err", err.Error()) + res.WriteHeader(400) + res.Write([]byte("Invalid JSON payload, err: \"" + err.Error() + "\"")) + return + } + + res.Header().Set("Content-Type", "text/event-stream") + res.Header().Set("Cache-Control", "no-cache") + res.Header().Set("Connection", "keep-alive") + + for idx, command := range payload.Commands { + response += "Running command #" + fmt.Sprint(idx) + ": " + command + "\n" + exitCode, cmdRes, err := runCmd(log, workDir, command, uid, gid) + response += cmdRes + if err != nil { + response += "Command failed, err: " + err.Error() + "\n" + res.WriteHeader(400) + res.Write([]byte(response)) + return + } + if exitCode != 0 { + response += "Command failed, non-zero exit code: " + fmt.Sprint(exitCode) + "\n" + res.WriteHeader(400) + res.Write([]byte(response)) + return + } + } + + // If we get here, nothing have broken down, so 200 OK and send the full response + res.WriteHeader(200) + res.Write([]byte(response)) + }) + + mainLog.Info("Starting web server", "PORT", httpPort) + err = http.ListenAndServe(":"+httpPort, nil) + if err != nil { + mainLog.Error("Web server crashed", "err", err.Error()) + } +} diff --git a/openrc/README.md b/openrc/README.md new file mode 100644 index 0000000..5be48b8 --- /dev/null +++ b/openrc/README.md @@ -0,0 +1,12 @@ +# OpenRC init scripts + +OpenRC is used to run services in Alpine Linux, Gentoo and probably others. + +More info: https://github.com/OpenRC/openrc/blob/master/service-script-guide.md + +## Steps to install + +1. Copy init.d/runner to /etc/init.d/runner +2. Set correct permissions: `doas chown root:root /etc/init.d/runner && chmod 755 /etc/init.d/runner` +3. Create symlink in correct runlevel: `doas ln -s /etc/init.d/runner /etc/runlevels/default/runner` +4. Set API_KEYS in /etc/init.d/runner by editing the file as root \ No newline at end of file