diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f92a43e --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.DS_Store +bin/* +!**/.gitkeep +config.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..9772064 --- /dev/null +++ b/README.md @@ -0,0 +1,112 @@ +# n8n-launcher + +CLI utility to launch [n8n task runners](https://docs.n8n.io/PENDING). + +```sh +./n8n-launcher launch -type javascript +2024/11/15 13:53:33 Starting to execute `launch` command... +2024/11/15 13:53:33 Loaded config file loaded with a single runner config +2024/11/15 13:53:33 Changed into working directory: /Users/ivov/Development/n8n-launcher/bin +2024/11/15 13:53:33 Filtered environment variables +2024/11/15 13:53:33 Authenticated with n8n main instance +2024/11/15 13:53:33 Launching runner... +2024/11/15 13:53:33 Command: /usr/local/bin/node +2024/11/15 13:53:33 Args: [/Users/ivov/Development/n8n/packages/@n8n/task-runner/dist/start.js] +2024/11/15 13:53:33 Env vars: [LANG PATH TERM N8N_RUNNERS_N8N_URI N8N_RUNNERS_GRANT_TOKEN] +``` + +## Setup + +### Install + +- Install Node.js >=18.17 +- Install n8n >= PENDING_VERSION +- Download launcher binary from [releases page](https://github.com/n8n-io/PENDING/releases) + +### Config + +Create a config file for the launcher at `/etc/n8n-task-runners.json`. + +Sample config file: + +```json +{ + "task-runners": [ + { + "runner-type": "javascript", + "workdir": "/usr/local/bin", + "command": "/usr/local/bin/node", + "args": [ + "/usr/local/lib/node_modules/n8n/node_modules/@n8n/task-runner/dist/start.js" + ], + "allowed-env": [ + "PATH", + "N8N_RUNNERS_GRANT_TOKEN", + "N8N_RUNNERS_N8N_URI", + "N8N_RUNNERS_MAX_PAYLOAD", + "N8N_RUNNERS_MAX_CONCURRENCY", + "NODE_FUNCTION_ALLOW_BUILTIN", + "NODE_FUNCTION_ALLOW_EXTERNAL", + "NODE_OPTIONS" + ] + } + ] +} +``` + +Task runner config fields: + +- `runner-type`: Type of task runner, currently only `javascript` supported +- `workdir`: Path to directory containing the task runner binary +- `command`: Command to execute to start task runner +- `args`: Args for command to execute, currently path to task runner entrypoint +- `allowed-env`: Env vars allowed to be passed to the task runner + +### Auth + +Generate a secret auth token (e.g. random string) for the launcher to authenticate with the n8n main instance. You will need to pass that token as `N8N_RUNNERS_AUTH_TOKEN` to the n8n main instance and to the launcher. During the `launch` command, the launcher will exchange this auth token for a short-lived grant token from the n8n instance, and pass the grant token to the runner. + +## Usage + +Once setup is complete, start the launcher: + +```sh +export N8N_RUNNERS_AUTH_TOKEN=... +export N8N_RUNNERS_N8N_URI=... +./n8n-launcher javascript +``` + +## Development + +1. Install Go >=1.23 + +2. Clone repo and create a [config file](#config) + +```sh +git clone https://github.com/n8n-io/PENDING-NAME +cd PENDING_NAME +touch config.json && echo '' > config.json +sudo mv config.json /etc/n8n-task-runners.json +``` + +3. Make changes to launcher. + +4. Start n8n: + +```sh +export N8N_RUNNERS_ENABLED=true +export N8N_RUNNERS_MODE=external +export N8N_RUNNERS_LAUNCHER_PATH=... +export N8N_RUNNERS_AUTH_TOKEN=... +pnpm start +``` + +5. Build and run launcher: + +```sh +go build -o bin cmd/launcher/main.go + +export N8N_RUNNERS_N8N_URI=... +export N8N_RUNNERS_AUTH_TOKEN=... +./bin/main javascript +``` diff --git a/bin/.gitkeep b/bin/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/cmd/launcher/main.go b/cmd/launcher/main.go new file mode 100644 index 0000000..bac58ca --- /dev/null +++ b/cmd/launcher/main.go @@ -0,0 +1,29 @@ +package main + +import ( + "flag" + "os" + + "n8n-launcher/internal/commands" + "n8n-launcher/internal/logs" +) + +func main() { + flag.Usage = func() { + logs.Logger.Printf("Usage: %s [runner-type]", os.Args[0]) + flag.PrintDefaults() + } + + if len(os.Args) < 2 { + logs.Logger.Fatal("Missing runner-type argument") + flag.Usage() + os.Exit(1) + } + + runnerType := os.Args[1] + cmd := &commands.LaunchCommand{RunnerType: runnerType} + + if err := cmd.Execute(); err != nil { + logs.Logger.Printf("Failed to execute command: %s", err) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d837a50 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module n8n-launcher + +go 1.23.3 diff --git a/internal/auth/token_exchange.go b/internal/auth/token_exchange.go new file mode 100644 index 0000000..d7bbbbf --- /dev/null +++ b/internal/auth/token_exchange.go @@ -0,0 +1,51 @@ +package auth + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" +) + +type grantTokenResponse struct { + Data struct { + Token string `json:"token"` + } `json:"data"` +} + +// FetchGrantToken exchanges the launcher's auth token for a less privileged +// grant token returned by the n8n main instance. The launcher will later pass +// this grant token to the task runner. +func FetchGrantToken(n8nUri, authToken string) (string, error) { + url := fmt.Sprintf("http://%s/runners/auth", n8nUri) + + payload := map[string]string{"token": authToken} + payloadBytes, err := json.Marshal(payload) + if err != nil { + return "", err + } + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(payloadBytes)) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("request to fetch grant token received status code %d", resp.StatusCode) + } + + var tokenResp grantTokenResponse + if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { + return "", err + } + + return tokenResp.Data.Token, nil +} diff --git a/internal/commands/command.go b/internal/commands/command.go new file mode 100644 index 0000000..f1ab912 --- /dev/null +++ b/internal/commands/command.go @@ -0,0 +1,5 @@ +package commands + +type Command interface { + Execute() error +} diff --git a/internal/commands/launch.go b/internal/commands/launch.go new file mode 100644 index 0000000..fddde07 --- /dev/null +++ b/internal/commands/launch.go @@ -0,0 +1,102 @@ +package commands + +import ( + "fmt" + "n8n-launcher/internal/auth" + "n8n-launcher/internal/config" + "n8n-launcher/internal/env" + "n8n-launcher/internal/logs" + "os" + "os/exec" +) + +type LaunchCommand struct { + RunnerType string +} + +func (l *LaunchCommand) Execute() error { + logs.Logger.Println("Started executing `launch` command") + + token := os.Getenv("N8N_RUNNERS_AUTH_TOKEN") + n8nUri := os.Getenv("N8N_RUNNERS_N8N_URI") + + if token == "" || n8nUri == "" { + return fmt.Errorf("both N8N_RUNNERS_AUTH_TOKEN and N8N_RUNNERS_N8N_URI are required") + } + + // 1. read configuration + + cfg, err := config.ReadConfig() + if err != nil { + logs.Logger.Printf("Error reading config: %v", err) + return err + } + + var runnerConfig config.TaskRunnerConfig + found := false + for _, r := range cfg.TaskRunners { + if r.RunnerType == l.RunnerType { + runnerConfig = r + found = true + break + } + } + + if !found { + return fmt.Errorf("config file does not contain requested runner type : %s", l.RunnerType) + } + + cfgNum := len(cfg.TaskRunners) + + if cfgNum == 1 { + logs.Logger.Println("Loaded config file loaded with a single runner config") + } else { + logs.Logger.Printf("Loaded config file with %d runner configs", cfgNum) + } + + // 2. change into working directory + + if err := os.Chdir(runnerConfig.WorkDir); err != nil { + return fmt.Errorf("failed to chdir into configured dir (%s): %w", runnerConfig.WorkDir, err) + } + + logs.Logger.Printf("Changed into working directory: %s", runnerConfig.WorkDir) + + // 3. filter environment variables + + defaultEnvs := []string{"LANG", "PATH", "TZ", "TERM"} + allowedEnvs := append(defaultEnvs, runnerConfig.AllowedEnv...) + runnerEnv := env.AllowedOnly(allowedEnvs) + + logs.Logger.Printf("Filtered environment variables") + + // 4. authenticate with n8n main instance + + grantToken, err := auth.FetchGrantToken(n8nUri, token) + if err != nil { + return fmt.Errorf("failed to fetch grant token from n8n main instance: %w", err) + } + + runnerEnv = append(runnerEnv, fmt.Sprintf("N8N_RUNNERS_GRANT_TOKEN=%s", grantToken)) + + logs.Logger.Println("Authenticated with n8n main instance") + + // 5. launch runner + + logs.Logger.Println("Launching runner...") + logs.Logger.Printf("Command: %s", runnerConfig.Command) + logs.Logger.Printf("Args: %v", runnerConfig.Args) + logs.Logger.Printf("Env vars: %v", env.Keys(runnerEnv)) + + cmd := exec.Command(runnerConfig.Command, runnerConfig.Args...) + cmd.Env = runnerEnv + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + err = cmd.Run() + if err != nil { + return fmt.Errorf("failed to launch task runner: %w", err) + } + + return nil +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..3164687 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,49 @@ +// Package config provides functions to use the launcher configuration file. +package config + +import ( + "encoding/json" + "fmt" + "os" +) + +const configPath = "/etc/n8n-task-runners.json" + +type TaskRunnerConfig struct { + // Type of task runner, currently only "javascript" supported + RunnerType string `json:"runner-type"` + + // Path to directory containing launcher (Go binary) + WorkDir string `json:"workdir"` + + // Command to execute to start task runner + Command string `json:"command"` + + // Arguments for command to execute, currently path to task runner entrypoint + Args []string `json:"args"` + + // Env vars allowed to be passed by launcher to task runner + AllowedEnv []string `json:"allowed-env"` +} + +type LauncherConfig struct { + TaskRunners []TaskRunnerConfig `json:"task-runners"` +} + +func ReadConfig() (*LauncherConfig, error) { + data, err := os.ReadFile(configPath) + if err != nil { + return nil, fmt.Errorf("failed to open config file at %s: %w", configPath, err) + } + + var config LauncherConfig + if err := json.Unmarshal(data, &config); err != nil { + return nil, fmt.Errorf("failed to parse config file at %s: %w", configPath, err) + } + + if len(config.TaskRunners) == 0 { + return nil, fmt.Errorf("found no task runner configs inside launcher config") + } + + return &config, nil +} diff --git a/internal/env/env.go b/internal/env/env.go new file mode 100644 index 0000000..c13c65a --- /dev/null +++ b/internal/env/env.go @@ -0,0 +1,39 @@ +package env + +import ( + "os" + "strings" +) + +// AllowedOnly filters the current environment down to only those +// environment variables in the allow list. +func AllowedOnly(allowed []string) []string { + var filtered []string + + for _, env := range os.Environ() { + parts := strings.SplitN(env, "=", 2) + if len(parts) != 2 { + continue + } + + key := parts[0] + for _, allowedKey := range allowed { + if key == allowedKey { + filtered = append(filtered, env) + break + } + } + } + + return filtered +} + +// Keys returns the keys of the environment variables. +func Keys(env []string) []string { + keys := make([]string, len(env)) + for i, env := range env { + keys[i] = strings.SplitN(env, "=", 2)[0] + } + + return keys +} diff --git a/internal/logs/logger.go b/internal/logs/logger.go new file mode 100644 index 0000000..3845bfa --- /dev/null +++ b/internal/logs/logger.go @@ -0,0 +1,8 @@ +package logs + +import ( + "log" + "os" +) + +var Logger = log.New(os.Stdout, "", log.LstdFlags)