Initial commit
This commit is contained in:
commit
134351603c
7
.env.example
Normal file
7
.env.example
Normal file
|
@ -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
|
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
.env
|
||||||
|
bin
|
||||||
|
runner
|
7
README.md
Normal file
7
README.md
Normal file
|
@ -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!!!!
|
9
go.mod
Normal file
9
go.mod
Normal file
|
@ -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
|
6
go.sum
Normal file
6
go.sum
Normal file
|
@ -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=
|
375
main.go
Normal file
375
main.go
Normal file
|
@ -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())
|
||||||
|
}
|
||||||
|
}
|
12
openrc/README.md
Normal file
12
openrc/README.md
Normal file
|
@ -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
|
Loading…
Reference in New Issue
Block a user