Go package for executing remote CLI commands with input/output file support over HTTP.
- Remote Command Execution: Execute CLI commands on remote servers via HTTP
- File Transfer: Send input files and receive output files with commands
- Result Patterns: Glob patterns to select which result files to return
- Stdin Support: Provide standard input data to commands
- Local Execution: Execute commands locally with the same interface
- Client/Server Architecture: HTTP-based communication with gob encoding
- Graceful Shutdown: Server supports graceful shutdown on signals
- Command Whitelisting: Server restricts execution to explicitly allowed commands
- Timeout Support: Configurable timeouts for command execution
- Isolated Execution: Each command runs in a unique temporary directory
- Context Support: Full context.Context support for cancellation
go get github.com/domonda/go-rcomStart a server that allows execution of specific commands:
package main
import (
"log"
"github.com/domonda/go-rcom"
)
func main() {
// Allow execution of 'convert' and 'ffmpeg' commands
err := rcom.ListenAndServe(8080, true, "convert", "ffmpeg")
if err != nil {
log.Fatal(err)
}
}The server will:
- Listen on port 8080
- Enable graceful shutdown on SIGTERM/SIGINT/SIGHUP
- Only execute whitelisted commands
Execute commands remotely:
package main
import (
"context"
"log"
"time"
"github.com/domonda/go-rcom"
"github.com/ungerik/go-fs"
)
func main() {
ctx := context.Background()
// Create client
client := rcom.NewClient(
rcom.ClientWithHost("localhost"),
rcom.ClientWithPort(8080),
rcom.ClientWithCmds("convert"),
rcom.ClientWithTimeOut(30 * time.Second),
)
// Prepare input files
inputFile := fs.File("/path/to/input.png")
// Execute command with input file and collect result files
result, err := client.Execute(
ctx,
[]string{"-resize", "50%", "input.png", "output.png"}, // command args
[]fs.FileReader{inputFile}, // input files
"output.png", // result file patterns
)
if err != nil {
log.Fatal(err)
}
// Save result file
err = result.WriteTo(fs.File("/path/to/output.png"))
if err != nil {
log.Fatal(err)
}
log.Printf("Command output: %s", result.Output)
log.Printf("Exit code: %d", result.ExitCode)
}Commands are defined using the Command struct:
cmd := &rcom.Command{
Name: "convert", // Command to execute
Args: []string{"-resize", "50%", "in.png", "out.png"}, // Arguments
Files: map[string][]byte{ // Input files (filename -> content)
"in.png": imageData,
},
Stdin: []byte("some input"), // Optional stdin data
ResultFilePatterns: []string{"*.png"}, // Glob patterns for result files
NonErrorExitCodes: map[int]bool{1: true}, // Exit codes to not treat as errors
}Execute commands locally without a server:
result, callID, err := rcom.ExecuteLocally(ctx, cmd)
if err != nil {
log.Fatal(err)
}
log.Printf("Call ID: %s", callID)
log.Printf("Output: %s", result.Output)
log.Printf("Stdout: %s", result.Stdout)
log.Printf("Stderr: %s", result.Stderr)
// Access result files
for filename, data := range result.Files {
log.Printf("Result file: %s (%d bytes)", filename, len(data))
}Execute commands on a remote server:
result, err := rcom.ExecuteRemotely(ctx, "http://localhost:8080", cmd)
if err != nil {
log.Fatal(err)
}Both local and remote execution implement the Executor interface:
var exec rcom.Executor
// Use local execution
exec = rcom.LocalExecutor()
// Or create a client that implements Executor
client := rcom.NewClient(
rcom.ClientWithHost("localhost"),
rcom.ClientWithPort(8080),
rcom.ClientWithCmds("convert"),
)
// Client implements Executor via ExecuteWithCommand
result, err := exec.Execute(ctx, cmd)Configure clients with functional options:
client := rcom.NewClient(
rcom.ClientWithHost("example.com"), // Set server host
rcom.ClientWithPort(8080), // Set server port
rcom.ClientWithCmds("convert", "ffmpeg"), // Allowed commands
rcom.ClientWithTimeOut(60 * time.Second), // Request timeout
)For clients that support multiple commands:
client := rcom.NewClient(
rcom.ClientWithHost("localhost"),
rcom.ClientWithPort(8080),
rcom.ClientWithCmds("convert", "ffmpeg"),
)
// Execute specific command
result, err := client.ExecuteWithCommand(
ctx,
"convert", // which command
[]string{"-resize", "50%", "in.png", "out.png"}, // args
[]fs.FileReader{inputFile}, // input files
"out.png", // result file patterns
)Provide standard input to commands:
cmd := &rcom.Command{
Name: "base64",
Args: []string{"-d"},
Stdin: []byte("SGVsbG8gV29ybGQh"),
}
result, _, err := rcom.ExecuteLocally(ctx, cmd)
// result.Stdout contains the decoded outputSome commands use non-zero exit codes that shouldn't be treated as errors:
cmd := &rcom.Command{
Name: "grep",
Args: []string{"pattern", "file.txt"},
NonErrorExitCodes: map[int]bool{
1: true, // grep returns 1 when no matches found
},
}Use glob patterns to select result files:
cmd := &rcom.Command{
Name: "my-generator",
ResultFilePatterns: []string{"*.json", "output-*.txt", "report.pdf"},
}
result, _, err := rcom.ExecuteLocally(ctx, cmd)
// All matching files are in result.Files
for filename, data := range result.Files {
log.Printf("Generated: %s (%d bytes)", filename, len(data))
}err := rcom.ListenAndServe(3000, true, "allowed-cmd1", "allowed-cmd2")err := rcom.ListenAndServe(8080, false, "convert")Configure the timeout for graceful shutdown:
rcom.GracefulShutdownTimeout = 2 * time.Minute
err := rcom.ListenAndServe(8080, true, "convert")Use a custom logger:
import "github.com/domonda/golog"
logger := golog.NewLogger("rcom-server")
rcom.SetLogger(logger)- Client creates a
Commandwith name, args, files, and result patterns - Client encodes command using
encoding/goband sends via HTTP POST - Server receives request, validates command is allowed
- Server creates unique temporary directory for execution
- Server writes input files to temp directory
- Server executes command in temp directory with stdin
- Server collects output, stderr, exit code, and result files matching patterns
- Server encodes
Resultand sends back to client - Server cleans up temporary directory
- Client decodes result and makes files available
Each command execution gets a unique temporary directory:
- Located in system temp dir
- Named with a UUID v7 (time-sortable)
- Input files are written here
- Command executes with this as working directory
- Result files are read from here
- Directory is cleaned up after execution
- Command Whitelisting: Server only executes explicitly allowed commands
- No Path Traversal: Filenames cannot contain path separators
- Isolated Execution: Each command runs in its own temporary directory
- Subprocess Cleanup: Child processes are killed on cancellation
- Input Validation: Commands are validated before execution
All errors are returned with context. Common error scenarios:
- Command not allowed: Server rejects command not in whitelist
- Invalid filenames: Filenames with path separators are rejected
- Non-zero exit codes: Treated as errors unless in
NonErrorExitCodes - Missing result files:
Result.WriteToreturns error if file not found - Context cancellation: Commands respect context cancellation
- Timeout: Commands timeout based on client configuration
- Whitelist Commands: Only allow necessary commands on the server
- Use Timeouts: Always set reasonable timeouts on clients
- Validate Results: Check exit codes and result file presence
- Clean Error Handling: Non-zero exit codes should be explicitly handled
- Use Result Patterns: Specify patterns to avoid returning unnecessary files
- Context Propagation: Always pass context for cancellation support
- Unique Call IDs: Use the returned call ID for logging and debugging
Server:
package main
import (
"log"
"github.com/domonda/go-rcom"
)
func main() {
// Only allow ImageMagick convert
err := rcom.ListenAndServe(8080, true, "convert")
if err != nil {
log.Fatal(err)
}
}Client:
package main
import (
"context"
"log"
"time"
"github.com/domonda/go-rcom"
"github.com/ungerik/go-fs"
)
func convertImage(inputPath, outputPath string) error {
ctx := context.Background()
client := rcom.NewClient(
rcom.ClientWithHost("conversion-server.local"),
rcom.ClientWithPort(8080),
rcom.ClientWithCmds("convert"),
rcom.ClientWithTimeOut(30 * time.Second),
)
inputFile := fs.File(inputPath)
result, err := client.Execute(
ctx,
[]string{"-resize", "800x600", inputFile.Name(), "output.jpg"},
[]fs.FileReader{inputFile},
"output.jpg",
)
if err != nil {
return err
}
return result.WriteTo(fs.File(outputPath))
}
func main() {
err := convertImage("/tmp/input.png", "/tmp/output.jpg")
if err != nil {
log.Fatal(err)
}
log.Println("Image converted successfully")
}Run the test suite:
go test ./...- github.com/domonda/go-types - Type system extensions
- github.com/domonda/golog - Structured logging
- github.com/ungerik/go-fs - File system abstraction
See LICENSE file