Skip to content

Lightweight Go library for templated shell command execution (local & SSH), with structured results parsing and file transfer support

License

Notifications You must be signed in to change notification settings

NGRsoftlab/rexec

Repository files navigation

rexec

-rexec is a Go library that executes shell commands locally or over SSH, parses outputs into Go types, transfers files, and manages SSH connection retries and timeouts—without requiring agents on target machines.

By running ad-hoc commands and interpreting their results, you receive immediate feedback from remote hosts, when you can’t deploy or maintain persistent agents on remote hosts.

Quick start

  1. connection via SSH,
  2. uploading the file via SFTP,
  3. checking the existence of the file,
  4. parsing the rights/owner/date of the file attribute via ls,
  5. outputting the result from a Go structure.
package main

import (
	"context"
	"fmt"
	"path"
	"time"
	
	"github.com/ngrsoftlab/rexec"
	"github.com/ngrsoftlab/rexec/command"
	"github.com/ngrsoftlab/rexec/parser/examples"
	"github.com/ngrsoftlab/rexec/ssh"
)

func main() {
	// 1. setting up ssh client
	sshCfg, err := ssh.NewConfig(
		"alice", "example.com", 22,
		ssh.WithPasswordAuth("secret"),
		ssh.WithRetry(3, 5*time.Second),
		ssh.WithKeepAlive(30*time.Second),
	)
	if err != nil {
		panic(err)
	}
	client, err := ssh.NewClient(sshCfg)
	if err != nil {
		panic(err)
	}
	defer client.Close()
	
	ctx := context.Background()
	
	// 2. upload file by SFTP
	data := []byte("Hello, rexec!")
	remoteDir := "/tmp/rexec"
	fileName := "hello.txt"
	spec := &rexec.FileSpec{
		TargetDir:  remoteDir,
		Filename:   fileName,
		Mode:       0644,
		FolderMode: 0755,
		Content:    &rexec.FileContent{Data: data},
	}
	// scp := ssh.NewSCPTransfer(client) // switch protocol is so simple
	sftp := ssh.NewSFTPTransfer(client)
	if err := sftp.Copy(ctx, spec); err != nil {
		panic(err)
	}
	
	// 3. check uploaded file existence
	var exists bool
	remotePath := path.Join(remoteDir, fileName)
	cmdExist := command.New(
		"test -f %s && echo true || echo false",
		command.WithArgs(remotePath),
		command.WithParser(&examples.PathExistence{}),
	)
	exists, err = rexec.RunParse[ssh.RunOption, bool](ctx, client, cmdExist)
	if err != nil {
		panic(err)
	}
	fmt.Printf("Exists: %v\n", exists)
	
	// 4. gathering details of uploaded file
	var entries []examples.LsEntry
	cmdLs := command.New(
		"ls -la %s",
		command.WithArgs(remotePath),
		command.WithParser(&examples.LsParser{}),
	)
	entries, err = rexec.RunParse[ssh.RunOption, []examples.LsEntry](ctx, client, cmdLs)
	if err != nil {
		panic(err)
	}
	
	// 5. print result
	if len(entries) > 0 {
		e := entries[0]
		fmt.Printf("File: %s\n", e.Name)
		fmt.Printf("Owner: %s\n", e.Owner)
		fmt.Printf("Created: %s %s %s\n", e.Month, e.Day, e.TimeOrYear)
	}
}

Features

  • Unified API: Local and SSH execution via Client[O any] interface. Where O is ssh.RunOption or local.RunOption
  • Structured Parsing: Convert command output into Go structs with parser.Parser
  • File Transfers: Copy files using FileSpec over local FS, SCP, or SFTP
  • SSH Connection Retries: Automatic dial retries on SSH connection failures
  • TCP Keep-Alive: Prevent idle disconnections
  • Automatic PTY: Allocate a pseudo-TTY for interactive commands (e.g. sudo, passwd)
  • Context-Aware: Timeouts and cancellations via context.Context
  • Custom I/O Streams: Override stdin, stdout, stderr for using in websockets, logs. Includes support for real-time streaming of output
  • Concurrency Safety: Respects SSH server’s MaxSessions limit

Installation

# Install the library
go get github.com/ngrsoftlab/rexec

Configuration

Local Client

import "github.com/ngrsoftlab/rexec/local"

cfg := local.NewConfig().
  WithWorkDir("/tmp").       // default workdir
  WithEnvVars(map[string]string{ // environment for every run
    "GREETING": "Hello",
  })
client := local.NewClient(cfg)
defer client.Close()
  • WithWorkDir(path string): set default workdir.
  • WithEnvVars(map[string]string): set default environment.

SSH Client

import (
  "time"
  "github.com/ngrsoftlab/rexec/ssh"
)

sshCfg, err := ssh.NewConfig(
  "alice", "example.com", 22,
  ssh.WithPasswordAuth("secret"),           // password auth
  ssh.WithKnownHosts("~/.ssh/known_hosts"),
  ssh.WithRetry(3, 5*time.Second),          // SSH dial retry
  ssh.WithKeepAlive(30*time.Second),        // TCP keep-alive
  ssh.WithSudoPassword("sudoPass"),         // automatic sudo prompt
  ssh.WithWorkdir("/home/alice"),           // default remote dir
  ssh.WithMaxSessions(2),                   // concurrent sessions
)
if err != nil {
  // handle error
}
client, err := ssh.NewClient(sshCfg)
defer client.Close()

SSH Config Options

  • WithPort(int)
  • WithTimeout(time.Duration)
  • WithRetry(count int, interval time.Duration) (SSH dial only)
  • WithKeepAlive(time.Duration)
  • WithKnownHosts(path string)
  • WithSudoPassword(string)
  • WithEnvVars(map[string]string)
  • WithWorkdir(string)
  • WithMaxSessions(int)
  • Auth:
    • WithPasswordAuth(password string)
    • WithAgentAuth()
    • WithPrivateKeyPathAuth(path, passphrase string)
    • WithKeyBytesAuth([]byte, passphrase string)

Executing Commands

Constructing Commands

import "github.com/ngrsoftlab/rexec/command"

const listTpl = "ls -la %s"
cmd := command.New(
  listTpl,
  command.WithArgs("/var/log"),      // fmt.Sprintf args
  command.WithParser(&parser.LsParser{}), // parser
)
  • WithArgs(...any): append positional parameters.
  • WithParser(parser.Parser): attach parsing logic.

Client.Run

// dst is optional – pass nil to ignore parsing

// Example: local execution with override options
res, err := localClient.Run(ctx, cmd, &dst, local.WithWorkdir("/data"))
if err != nil {
// handle error; res.Stderr contains stderr
}
// Example: SSH execution with env var override
res, err = sshClient.Run(ctx, cmd, &dst, ssh.WithEnvVar("KEY", "value"))
if err != nil {
// handle error; res.ExitCode holds exit status
}

Local (local.RunOption):

  • WithWorkdir(string)
  • WithEnvVar(key, value)
  • WithStdout(io.Writer)
  • WithStderr(io.Writer)
  • WithStdin(io.Reader)

SSH (ssh.RunOption):

  • WithEnvVar(key, value)
  • WithStdout(io.Writer)
  • WithStderr(io.Writer)
  • WithStdin(io.Reader)
  • WithStreaming(): real-time output
  • WithoutBuffering(): disable internal buffers

Helpers & Generics

import "github.com/ngrsoftlab/rexec"

// ignore parsing and return error only
err := rexec.RunNoResult[O](ctx, client, cmd, opts...)

// get raw outputs:
out, errOut, exit, err := rexec.RunRaw[O](ctx, client, cmd, opts...)

// parse into T:
dst, err := rexec.RunParse[O, T](ctx, client, cmd, opts...)
  • O = local.RunOption or ssh.RunOption;
  • T = result type.

Parsers

Implement parser.Parser to handle any command:

type Parser interface {
  Parse(raw *RawResult, dst any) error
}

Built-in Parsers

  • Located under parser/examples:
    • PathExistence: stdout "true"/"false" → bool
    • LsParser: parse ls -la → []LsEntry

Custom Parsers

const uptimeTpl = "uptime -p" // create template

type UptimeInfo struct { Since string }

type UptimeParser struct{}

func (p *UptimeParser) Parse(raw *parser.RawResult, dst any) error {
  info, ok := dst.(*UptimeInfo)
  if !ok { return fmt.Errorf("dst must be *UptimeInfo") }
  info.Since = strings.TrimPrefix(raw.Stdout, "up ")
  return raw.Err
}

var info UptimeInfo
cmd := command.New(uptimeTpl, command.WithParser(&UptimeParser{}))
_, err := client.Run(ctx, cmd, &info)

File Transfers

Use rexec.FileSpec:

type FileSpec struct {
  TargetDir  string
  Filename   string
  Mode       os.FileMode
  FolderMode os.FileMode
  Content    *FileContent
}

FileContent

FileContent supports three source types; choose one per FileSpec:

  1. In-Memory Data

    content := &rexec.FileContent{Data: []byte("small payload")}
  2. Filesystem Path

    content := &rexec.FileContent{SourcePath: "/path/to/file.txt"}
  3. Reader

    f, _ := os.Open("/var/log/stream.log")
    content := &rexec.FileContent{Reader: f}

    • Use for large files or runtime-generated streams to avoid buffering overhead.

Internally, ReaderAndSize() returns:

reader, size, err := content.ReaderAndSize()

The FileContent.ReaderAndSize() method encapsulates logic to produce an io.ReadCloser and its length. Its behavior depends on which field is set:

  1. Data []byte

    • Returns io.NopCloser(bytes.NewReader(Data)) and int64(len(Data)).
    • Zero-seeking overhead; length is known immediately.
  2. SourcePath string

    • Opens the file via os.Open(SourcePath).
    • Calls File.Stat() to get size, then returns file handle and size.
    • Errors if file does not exist or is inaccessible.
  3. Reader io.Reader

    • If Reader implements io.Seeker, it seeks to determine current position and end to calculate
    • If Reader is not seekable, returns error: "reader is not seekable".
    • Use this when you have a stream that supports seeking (e.g., bytes.Reader) or accept unknown size.

Importance of Seekable Readers

  • Seekable readers allow accurate size reporting, necessary for protocols like SCP that require upfront length.
  • Non-seekable streams must implement custom logic or be wrapped if size is needed

Use Cases

  • In-memory: when Data is small and performance matters.
  • File path: for large existing files; OS handles buffering.
  • Seekable stream: for random-access buffers or replayable streams.

Local

transfer := local.NewTransfer()
err := transfer.Copy(ctx, &rexec.FileSpec{...})

SCP

scp := ssh.NewSCPTransfer(sshClient)
err := scp.Copy(ctx, spec)

SFTP

sftp := ssh.NewSFTPTransfer(sshClient)
err := sftp.Copy(ctx, spec)

SSH Connection Management & PTY

Connection Options (SSH-only)

  • ssh.WithRetry(count int, interval time.Duration): retry SSH dialing up to count times with interval delay on connection failures; does not retry failed commands.
  • ssh.WithKeepAlive(duration time.Duration): send TCP keep-alive messages at the specified interval to keep the SSH connection alive.

PTY Allocation & Sudo Handling

  • Automatic PTY: commands containing keywords like sudo, ssh, or docker login trigger a pseudo-terminal allocation, enabling interactive prompts. - Sudo Password: if ssh.WithSudoPassword(password) when set, the client monitors stdout for password: prompts and writes the provided password to stdin automatically.

Session Limits

Ensures the SSH client never exceeds the host’s allowed concurrent sessions. Recommended range 1-4 concurrent sessions. if problems occur, reduce the value Configured via:

    sshCfg, _ := ssh.NewConfig(
      "user", "host", 22,
      ssh.WithMaxSessions(3), // allow up to 3 simultaneous sessions
    )
  • If the limit is reached, OpenSession blocks until a slot frees or the context expires.

Stream Overrides and internal buffer control

Customize command I/O for live integrations and buffering control:

  • ssh.WithStreaming(): immediately writes each chunk of stdout/stderr to your WithStdout/WithStderr writers as it arrives, rather than waiting for command completion. Use-cases:
  • Live logs in a web dashboard or CLI progress indicators
  • Pushing real-time output over WebSockets
  • Interactive feedback loops in GUIs or monitoring tools

Without streaming, output is accumulated internally and made available only after the command finishes.

  • ssh.WithoutBuffering(): turns off the internal output buffers entirely; all data is sent directly to your writers. Benefits include:
  • Reduced memory usage when handling large or continuous streams
  • Predictable delivery in streaming pipelines or when chaining commands

If buffering is disabled and streaming is not enabled, the library will not store any output in res.Stdout/res.Stderr — all data goes to your writers.

Example of real-time WebSocket forwarding with minimal memory overhead:

wsWriter := NewWebSocketWriter(conn)
res, err := sshClient.Run(
  ctx,
  cmd,
  nil,
  ssh.WithStdout(wsWriter),
  ssh.WithStderr(wsWriter),
  ssh.WithStreaming(),
  ssh.WithoutBuffering(),
)
if err != nil {
  // handle error; live output streamed via wsWriter
}

Error Code Mapping

By default, ExitCodeMapper is applied automatically within every Run call, translating known exit codes into descriptive errors.
For advanced use cases (e.g., custom error analysis), you can manually invoke the mapper:

import "github.com/ngrsoftlab/rexec/utils"

// Run and receive raw result
res, err := sshClient.Run(ctx, cmd, nil)

// Default behavior: err includes mapped message
if err != nil {
  // err.Error() contains human-readable exit description
}

// Manual mapping example
mapper := utils.NewDefaultExitCodeMapper()
if res.ExitCode != 0 {
  desc := mapper.Lookup(res.ExitCode)
  log.Printf("Custom error mapping: code %d => %s", res.ExitCode, desc)
}

Use manual mapping when you need to log, categorize, or transform exit statuses beyond the default error message.

© 2025 NGRSOFTLAB

About

Lightweight Go library for templated shell command execution (local & SSH), with structured results parsing and file transfer support

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Languages