transport

package module
v0.1.7 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 3, 2026 License: BSD-3-Clause Imports: 26 Imported by: 0

README

transport

Pure-Go (CGO=0) remote command execution — local, SSH, and WinRM connections, plus sudo/su/doas privilege escalation — shared across orchestration tools (consumed by go-ansible and go-puppet-bolt).

CI Go Reference License

Documentation

Overview

Package transport implements remote command execution — the low-level primitives an orchestration tool's task/module logic runs commands and moves files through, whether the target is the control node itself (Local), a Unix-like host over SSH (SSH), or a Windows host over WinRM (WinRM) — plus privilege escalation (Become: sudo/su/doas) as a decorator over any of them.

It is deliberately scoped to these primitives and nothing tool-specific: no playbooks, no catalogs, no task manifests. Ansible's modules and Puppet Bolt's tasks both reduce, in the end, to "run this command" and "put this file there" — this package is that reduction, shared instead of reimplemented per tool.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type BecomeConfig

type BecomeConfig struct {
	Method   BecomeMethod // default BecomeSudo
	User     string       // default "root"
	Password string       // optional; empty assumes passwordless (NOPASSWD sudoers, or doas persist)
}

BecomeConfig configures privilege escalation, Ansible's `become:`.

type BecomeMethod

type BecomeMethod string

BecomeMethod names a privilege-escalation program.

const (
	BecomeSudo BecomeMethod = "sudo"
	BecomeSu   BecomeMethod = "su"
	BecomeDoas BecomeMethod = "doas"
)

type Connection

type Connection interface {
	// Exec runs cmd through the target's shell (one command line, not an
	// argv — matching how both Ansible and Bolt build task invocations)
	// and returns its captured output and exit code. A non-zero exit is
	// not itself an error; err is only for a failure to run the command
	// at all. stdin, if non-nil, is streamed to the command's standard
	// input — how a become/run-as password reaches sudo/su without ever
	// appearing in argv or an environment variable.
	Exec(ctx context.Context, cmd string, stdin io.Reader) (Result, error)

	// Put copies the local file at localPath to remotePath on the
	// target.
	Put(ctx context.Context, localPath, remotePath string, opts PutOptions) error

	// Fetch copies the file at remotePath on the target to the local
	// file at localPath.
	Fetch(ctx context.Context, remotePath, localPath string) error

	// Remove deletes remotePath on the target. Removing a path that
	// does not exist is not an error.
	Remove(ctx context.Context, remotePath string) error

	// TempPath returns a fresh path under the target's temp directory
	// ending in base, using the target's own path syntax (forward
	// slashes for Local/SSH, backslashes under WinRM).
	TempPath(base string) string

	// Close releases any resources (an SSH/WinRM connection, an open
	// shell).
	Close() error
}

Connection is how a caller reaches its target: run a shell command, move files in either direction, and clean up after itself. Every task/module operation is expressed in terms of these primitives so the same higher-level logic runs unchanged whether the connection is local, SSH, or WinRM.

func Become

func Become(conn Connection, cfg BecomeConfig) Connection

Become wraps conn so every Exec runs as BecomeConfig.User via the configured escalation method. Put/Fetch/Close pass through unchanged (Ansible's own become plumbing only affects command execution, not file transfer, since the transferred file is written by the connection's own user and then chmod/chowned by an escalated task).

Known gap: the wrapper returned here does not implement Streamer, even when conn does. Exec's become handling works by printing a random marker after escalation succeeds and slicing everything before it out of the buffered result (see successMarker below) — doing the equivalent safely on a live stream means scanning the stdout stream for that marker in real time and only starting to hand bytes to the caller once it has been seen, while still correctly forwarding a become password to the escalation program's stdin ahead of the wrapped command's own stdin. That is buildable, but not something to get subtly wrong under time pressure, so it is deliberately left undone rather than shipped half-right: conn.(transport.Streamer) on a Become-wrapped connection fails the type assertion, same as WinRM. Callers needing both become and a live interactive session need to wait for a follow-up that implements this deliberately, or run their interactive session unprivileged.

type HTTPDoer added in v0.1.3

type HTTPDoer interface {
	Do(*http.Request) (*http.Response, error)
}

HTTPDoer is the seam through which WinRM performs HTTP requests. The default implementation is an *http.Client; tests inject a fake.

type Local

type Local struct {
	// Shell is the interpreter commands are run through. Defaults to
	// "sh" resolved via PATH.
	Shell string
	// TempDir is where TempPath builds paths under. Defaults to
	// os.TempDir().
	TempDir string
}

Local is the "local" connection: the target IS the control node. Running the command through a real shell (rather than an argv exec.Command) is not a shortcut — it's what a local task/module runner needs, since a command's args are shell syntax (pipes, redirects, globs) that only a shell interprets.

func NewLocal

func NewLocal() *Local

NewLocal returns a Local connection using "sh" resolved via PATH.

This is deliberately the bare name "sh", not the absolute path "/bin/sh": os/exec's Windows LookPath only recognizes "\" and ":" as path separators, not "/", so an absolute-looking POSIX path like "/bin/sh" is instead treated as a literal bare command name and searched for on %PATH% — where it can never exist — failing every local exec on Windows with "executable file not found in %PATH%". A bare "sh" resolves correctly on both POSIX (finds /bin/sh or /usr/bin/sh via PATH) and Windows (finds Git for Windows' sh.exe, which GitHub's windows-latest runners — and most developer machines with Git installed — already have on PATH).

func (*Local) Close

func (l *Local) Close() error

func (*Local) Exec

func (l *Local) Exec(ctx context.Context, cmd string, stdin io.Reader) (Result, error)

func (*Local) Fetch

func (l *Local) Fetch(ctx context.Context, remotePath, localPath string) error

func (*Local) NewSession added in v0.1.4

func (l *Local) NewSession(ctx context.Context) (Session, error)

NewSession opens a live streaming session for a local command, implementing Streamer. The command itself is not known until Start, so this builds the *exec.Cmd now with an empty placeholder "-c" argument, which Start fills in later — letting StdinPipe/StdoutPipe/StderrPipe use os/exec's own Cmd.StdinPipe/StdoutPipe/StderrPipe (real OS pipes) instead of a manually bridged io.Pipe.

That distinction matters, and cost a real deadlock to learn: assigning an arbitrary io.Reader/io.Writer to Cmd.Stdin/Stdout/Stderr makes os/exec spawn its own internal copy goroutine, and Cmd.Wait blocks until every one of those finishes — including the stdin one, which only sees io.EOF once the write end is closed. A caller that requests StdinPipe (as any real interactive session does, to be able to forward keystrokes) but has no need to write anything — a command that doesn't read stdin, or a caller closing the session on an unrelated event before ever touching stdin — would then hang in Wait forever, since nothing else ever closes that write end. Cmd's own *Pipe methods don't have this problem: they hand back a real OS pipe end directly, with no bridging goroutine for Wait to wait on.

func (*Local) Put

func (l *Local) Put(ctx context.Context, localPath, remotePath string, opts PutOptions) error

func (*Local) Remove

func (l *Local) Remove(ctx context.Context, remotePath string) error

func (*Local) TempPath

func (l *Local) TempPath(base string) string

type PutOptions

type PutOptions struct {
	// Executable chmods the remote file +x after writing (POSIX targets
	// only; WinRM implementations ignore it — Windows has no exec bit).
	Executable bool
	// MkdirParents creates the destination's parent directory first.
	MkdirParents bool
}

PutOptions configures a file upload.

type Result

type Result struct {
	Stdout string
	Stderr string
	RC     int
}

Result is the outcome of running one command: its captured output and exit code.

type SSH

type SSH struct {
	// contains filtered or unexported fields
}

SSH is a live SSH connection to one target host. Every Exec opens its own session, matching how SSH sessions work (one command per session).

func DialSSH

func DialSSH(ctx context.Context, cfg SSHConfig) (*SSH, error)

DialSSH connects and authenticates to cfg.Host, trying (in order) explicit password, private key, and ssh-agent — whichever of those cfg populates.

func (*SSH) Close

func (s *SSH) Close() error

func (*SSH) Exec

func (s *SSH) Exec(ctx context.Context, cmd string, stdin io.Reader) (Result, error)

func (*SSH) Fetch

func (s *SSH) Fetch(ctx context.Context, remotePath, localPath string) error

func (*SSH) NewSession added in v0.1.4

func (s *SSH) NewSession(ctx context.Context) (Session, error)

NewSession opens a live streaming session over a fresh *ssh.Session on the already-dialed client, implementing Streamer. TTY is honored the same best-effort way Exec honors it: a RequestPty before Start, ignored if the server refuses it.

func (*SSH) Put

func (s *SSH) Put(ctx context.Context, localPath, remotePath string, opts PutOptions) error

Put streams localPath's contents to remotePath over a plain `cat >` session. This avoids an SFTP dependency; it needs only /bin/sh, cat, mkdir and chmod on the target — universally present.

func (*SSH) Remove

func (s *SSH) Remove(ctx context.Context, remotePath string) error

func (*SSH) TempPath

func (s *SSH) TempPath(base string) string

type SSHConfig

type SSHConfig struct {
	Host string
	Port int // default 22
	User string

	Password             string // optional
	PrivateKeyFile       string // optional, e.g. ~/.ssh/id_ed25519
	PrivateKeyBytes      []byte // optional, an already-loaded key (e.g. from inventory)
	PrivateKeyPassphrase string
	UseAgent             bool // authenticate via SSH_AUTH_SOCK

	// HostKeyCheck: when true (the default), the remote host key is
	// verified against KnownHostsFile (default ~/.ssh/known_hosts) and
	// the connection is refused on any mismatch or unknown host.
	HostKeyCheck   bool
	KnownHostsFile string

	// TempDir is the remote directory TempPath builds paths under.
	// Defaults to /tmp.
	TempDir string

	// TTY requests a pseudo-terminal for every Exec (best-effort: a
	// server that refuses it does not fail the command).
	TTY bool

	Timeout time.Duration // default 30s

	// Dialer establishes the transport-level TCP connection. Defaults
	// to net.Dialer honoring Timeout. Tests inject a dialer that reaches
	// an in-process server.
	Dialer func(ctx context.Context, network, addr string) (net.Conn, error)
}

SSHConfig configures an SSH connection to a Unix-like remote host.

type Session added in v0.1.4

type Session interface {
	// StdinPipe returns a writer for the session's standard input. Call
	// before Start.
	StdinPipe() (io.WriteCloser, error)
	// StdoutPipe returns a reader streaming standard output as it is
	// produced. Call before Start.
	StdoutPipe() (io.Reader, error)
	// StderrPipe returns a reader streaming standard error as it is
	// produced. Call before Start.
	StderrPipe() (io.Reader, error)
	// Start begins running cmd. Non-blocking.
	Start(cmd string) error
	// Wait blocks until the command exits and returns its exit code. A
	// non-zero exit is not itself an error; err is only for a failure to
	// wait on the command at all.
	Wait() (int, error)
	// Close releases the session's resources, terminating the remote
	// process if Wait has not yet returned. Safe to call after Wait, and
	// safe to call more than once.
	Close() error
}

Session is a live, streaming command execution — the interactive counterpart to Connection.Exec's single buffered call. Where Exec hands back one Result after the command has already finished, a Session lets the caller drive its stdin/stdout/stderr directly as plain pipes while the command runs: an interactive multi-host shell needs to forward keystrokes to a remote process and show its output as it is produced, not after the fact. Nothing is buffered by the library — the caller owns pacing and framing.

The usual sequence is: request the pipes you need, Start the command, pump stdin/stdout/stderr concurrently, then Wait for the exit code. Close releases the session's resources at any point, terminating the remote process if Wait has not yet returned.

type Streamer added in v0.1.4

type Streamer interface {
	NewSession(ctx context.Context) (Session, error)
}

Streamer is implemented by a Connection that can also open a live Session for callers needing real-time interactivity instead of Exec's buffered result. Local and SSH implement it.

WinRM does not: WS-Management's Command/Send/Receive shell protocol is poll-based (Receive returns whatever output has accumulated since the last poll, not a continuous stream), so it does not map onto the continuous-pipe shape Session assumes without either fabricating fake liveness or accepting periodic stalls that would silently degrade what "streaming" promises. Left unimplemented rather than shipped half-true.

Become-wrapped connections also do not implement Streamer, for a narrower reason: see Become's doc comment.

Callers should type-assert (conn.(transport.Streamer)) and handle absence explicitly, not assume every Connection supports it.

type WinRM

type WinRM struct {
	// contains filtered or unexported fields
}

WinRM is a live WinRM connection to one target host: one WS-Man shell, opened at Dial time and reused across every Exec/Put/Fetch/Remove until Close.

func DialWinRM

func DialWinRM(ctx context.Context, cfg WinRMConfig) (*WinRM, error)

DialWinRM opens a WS-Man shell on cfg.Host.

func (*WinRM) Close

func (w *WinRM) Close() error

func (*WinRM) Exec

func (w *WinRM) Exec(ctx context.Context, cmd string, stdin io.Reader) (Result, error)

Exec runs cmd through cmd.exe /c on the target's open shell. cmd is parsed by cmd.exe itself, with its own quoting rules; a caller that already has a program and a clean argv (no cmd.exe requoting wanted — e.g. running an uploaded executable with arguments that may contain spaces or quotes) should use WinRM.ExecArgv instead.

func (*WinRM) ExecArgv added in v0.1.2

func (w *WinRM) ExecArgv(ctx context.Context, command string, args []string, stdin io.Reader) (Result, error)

ExecArgv runs command with args as the WS-Man protocol's own Command and Arguments elements — no cmd.exe requoting layer, so each argument reaches the target byte for byte regardless of embedded spaces or quotes. Prefer this over Exec whenever the caller already has a program and a clean argv.

func (*WinRM) Fetch

func (w *WinRM) Fetch(ctx context.Context, remotePath, localPath string) error

Fetch reads remotePath by streaming its base64 encoding to stdout via PowerShell.

func (*WinRM) Put

func (w *WinRM) Put(ctx context.Context, localPath, remotePath string, opts PutOptions) error

Put writes content to dst by streaming base64 to a PowerShell decoder (WinRM has no plain file-transfer primitive). opts.Executable is ignored: Windows has no exec bit.

func (*WinRM) Remove

func (w *WinRM) Remove(ctx context.Context, remotePath string) error

func (*WinRM) TempPath

func (w *WinRM) TempPath(base string) string

type WinRMConfig

type WinRMConfig struct {
	Host string
	Port int // default 5986 (SSL) or 5985
	User string

	Password string

	SSL       bool   // default true
	SSLVerify bool   // default true
	CACert    string // path to a CA certificate PEM (custom trust root)

	// Transport selects the auth scheme: "negotiate" (default), "basic",
	// or "ssl" (TLS client certificate — set ClientCert/ClientKey).
	Transport  string
	ClientCert string
	ClientKey  string

	ConnectTimeout time.Duration // default 60s
	TempDir        string        // default `C:\Windows\Temp`
	Path           string        // WS-Man endpoint path, default "/wsman"

	// Environment is set on the shell at creation time (the WS-Man
	// protocol's own Environment block), so every Exec/ExecArgv on this
	// connection sees these variables — e.g. Bolt's PT_-prefixed task
	// parameters for the "environment" input method.
	Environment map[string]string

	// NewDoer builds the HTTP client used to reach the target. Defaults
	// to buildWinRMClient(cfg). Tests inject a doer that reaches an
	// in-process WS-Man server.
	NewDoer func(WinRMConfig) (HTTPDoer, error)
}

WinRMConfig configures a WinRM connection to a Windows remote host. It speaks WS-Management (SOAP 1.2 with WS-Addressing over HTTP/HTTPS) driving the MS-WSMV shell protocol: Create shell -> Command -> Send (stdin) -> Receive (stdout/stderr/CommandState/ExitCode) -> Signal (terminate) -> Delete shell.

Authentication is selected by Transport: "basic" sends HTTP Basic credentials, "negotiate" (the default) performs NTLM via the pure-Go github.com/Azure/go-ntlmssp round-tripper, and "ssl" uses TLS client-certificate authentication. Kerberos is not implemented.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL