sshcommands

package module
v1.1.0 Latest Latest
Warning

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

Go to latest
Published: Nov 4, 2026 License: MIT Imports: 27 Imported by: 0

README

ssh-commands

Go library for SSH/SFTP operations with a parameter-based API: upload (sync), list, delete, download, and fetch server host keys. No config struct dependency; all options are passed as parameters. Optional authenticated encryption (AES-256-CTR + HMAC-SHA256) for upload/download.

Go Reference Go Report Card CI

🇩🇪 Deutsche Version

Donationware for CFI Kinderhilfe. License: MIT with attribution (see LICENSE).

Requirements

  • Go 1.26 or later (see go.mod)

Installation

go get github.com/janmz/ssh-commands

Import:

import sshcommands "github.com/janmz/ssh-commands"

Quick Start

opts := &sshcommands.Opts{
    Host:    "example.com",
    Port:    22,
    User:    "deploy",
    KeyFile: "/home/user/.ssh/id_ed25519",
    HostKey: "ssh-ed25519 AAAAC3...", // or path to known_hosts
}

localFiles := []sshcommands.LocalFile{
    {Name: "file.zip", Path: "/local/file.zip", ModTime: time.Now(), Size: 1024},
}
err := sshcommands.Sync(opts, localFiles, "/remote/dir", "", nil)

Use at least KeyFile or Password for authentication. HostKey is required for verified connections (file path or inline key line; multiple keys separated by ||).

API Overview

Function Description
Sync Upload missing/newer files; delete remote files not in local list
List List non-directory entries in a remote directory
Delete Remove given file names from a remote directory
Download Download files matching a pattern (*, ? wildcards)
DownloadWithOptions Download with size limit / legacy decryption opt-in
ValidDownloadPattern Validate a download pattern before use
ValidRemoteFileName Reject ., path components, .. and control characters
FetchServerHostKey Fetch server host key without verification (setup)
HostKeyAlreadyPresent Check whether a key line is already configured
DialKnownHosts Connect using an OpenSSH known_hosts file
DialWithHostKeyCallback Connect with a custom host key callback
NewSFTPClient Open SSH + SFTP clients (lower-level access)
MkdirAllRemote Create remote directories recursively
UploadFileIfNewer Upload a single file when missing, newer or of different size
FormatKnownHostsHostToken Build host token for known_hosts entries
AppendKnownHostsLine Append a host token and key line to a file
AppendServerHostKey Fetch and append server key when the host is unknown

All operations accept an optional Logger (Info, Warn). Pass nil to disable logging.

Usage

Connection options

Build Opts with SSH connection parameters:

opts := &sshcommands.Opts{
    Host:     "example.com",
    Port:     22,
    User:     "deploy",
    KeyFile:  "/home/user/.ssh/id_ed25519",  // or Password: "<your-password>"
    KeyPassphrase: "<passphrase>",            // only for an encrypted KeyFile
    HostKey:  "ssh-ed25519 AAAAC3...",        // or path to known_hosts file
    Timeout:  10 * time.Second,               // optional; 0 => 30s (DefaultSSHTimeout)
    KeepAliveInterval: 15 * time.Second,      // optional; 0 => 15s, negative => off
}
  • Host may be a host name, IPv4 or IPv6 address (without brackets); Port 0 means 22, User is required.
  • Timeout limits TCP connect, SSH handshake and SFTP start; running transfers are not cut off. A hanging server is detected by SSH keepalives instead: after KeepAliveMaxMissed (default 3) unanswered requests the connection is closed and pending operations fail.
  • An encrypted private key needs KeyPassphrase. On Unix a warning is logged when the key file is readable by group or others.
  • An empty Host makes every operation return ErrRemoteNotConfigured (check with errors.Is if the remote is optional). An empty remote directory is rejected.
  • HostKey inline entries may be key-type base64 [comment] or full known_hosts lines; a value without spaces is treated as a file path ("host key file not found" when it does not exist).

Sync, Delete and Download reject (or skip) file names that are ., contain path components, .. or control characters, to prevent path traversal. On Windows, Download additionally skips names that cannot be created locally (CON, NUL.txt, trailing dot/space, <>:"|?*). On a host key mismatch only the SHA-256 fingerprint of the server key is logged; names from the server are logged quoted, local paths only by their base name.

Sync (upload and prune)

Upload local files that are missing on the remote, have a different size or are newer (compared in whole seconds); remove remote files that are not in the local list. Optional AES password for encryption.

localFiles := []sshcommands.LocalFile{
    {Name: "file.zip", Path: "/local/file.zip", ModTime: t, Size: 1024},
}
err := sshcommands.Sync(opts, localFiles, "/remote/dir", "", log)
  • All names are checked (path traversal, duplicates) before connecting.
  • An empty localFiles list is rejected, since it would delete every remote file; use Delete for that.
  • Uploads go to a temporary name (.sshcmd-upload-…) and are renamed when complete, so an aborted upload never looks like a finished file; leftovers are removed by the next Sync. The remote mtime is set to LocalFile.ModTime.
List
entries, err := sshcommands.List(opts, "/remote/dir", log)
// entries: []RemoteEntry{Name, ModTime, Size}
Delete
err := sshcommands.Delete(opts, "/remote/dir", []string{"old.zip"}, log)

All names are validated first (nothing is deleted if one is invalid); failed removals are collected and returned together.

Download
if !sshcommands.ValidDownloadPattern("backup_*.zip") {
    // handle invalid pattern
}
paths, err := sshcommands.Download(opts, "backup_*.zip", "/local/dir",
    "/remote/dir", aesPassword, log)
// paths: []string of written local paths

destDir is required. Existing local files are never overwritten: the file is saved as <name>.lokal, then <name>.lokal.2, …. Data is written to a temporary file in destDir first, so a failed download (e.g. wrong AES password) leaves nothing behind.

With an AES password every file must be encrypted in the current format; otherwise ErrNotEncrypted is returned. DownloadWithOptions adds a per-file size limit and the opt-in for files written before 1.1.0:

paths, err := sshcommands.DownloadWithOptions(opts, "backup_*.zip", "/local/dir",
    "/remote/dir", aesPassword, sshcommands.DownloadOptions{
        MaxSize:               2 << 30, // bytes per file; 0 = unlimited (ErrDownloadTooLarge)
        AllowLegacyDecryption: false,   // true only to migrate old backups
    }, log)
Fetch server host key

Connect without host key verification and return the server’s key line (e.g. for initial setup). The handshake is aborted right after the key exchange, so no credentials are sent; only Host and Port are needed:

keyLine, err := sshcommands.FetchServerHostKey(opts)
// keyLine: "ssh-ed25519 AAAAC3..."
Host key already present
ok, err := sshcommands.HostKeyAlreadyPresent(currentHostKeyValue, newKeyLine)

The check ignores host names; @revoked and @cert-authority lines never count as present.

Connect via known_hosts file
client, err := sshcommands.DialKnownHosts(opts, sshcommands.KnownHostsOptions{
    Path:            "/home/user/.ssh/known_hosts",
    FetchHostKey:    true,  // fetch server key and append when the host is unknown
    TrustOnMismatch: false, // development only, see below
}, log)
defer client.Close()

FetchHostKey creates the file when needed and adds the server key only when the file does not know the host yet (trust on first use, no credentials sent). Without it, DialKnownHosts returns an error when the file does not exist.

A changed key of a known host is a mismatch. TrustOnMismatch is meant for development environments only: it accepts the key presented in the failing connection, replaces the host's previous known_hosts entries with it (other hosts, comments and @revoked lines are kept), logs the new fingerprint as a warning and retries once. This disables MITM protection for changed keys.

AppendKnownHostsLine accepts only tokens naming exactly one host (host, [host]:port or a hashed token) and writes the key as key-type base64.

Upload a single file
client, err := sshcommands.DialKnownHosts(opts, kh, log)
// ...
err = sshcommands.MkdirAllRemote(client, "/remote/dir", log)
err = sshcommands.UploadFileIfNewer(client, "/local/file.zip",
    "/remote/dir/file.zip", log)
Lower-level SFTP access
sftpClient, sshClient, err := sshcommands.NewSFTPClient(opts, log)
defer sshClient.Close()
defer sftpClient.Close()

Encryption

When aesPassword is non-empty (surrounding whitespace is ignored), Sync encrypts and Download decrypts. Format (version 3, since 1.1.0):

"SSHC" | 0x03 | iterations(uint32) | salt(16) | nonce(16) | AES-256-CTR ciphertext | HMAC-SHA256(32)

PBKDF2-SHA256 (600,000 iterations, stored in the header; files with fewer than 100,000 or more than 10,000,000 are rejected) derives the AES and HMAC keys. The HMAC covers header, remote file name and ciphertext: a wrong password, a modified file or a file renamed/swapped on the server is reported as ErrDecryptIntegrity. Renaming an encrypted file on the server therefore makes it undecryptable under the new name. EncryptionOverhead (73 bytes) is the size difference to the plain file.

Files in the legacy format (salt | nonce | ciphertext, without integrity check, written before 1.1.0) are only decrypted with DownloadOptions.AllowLegacyDecryption (a warning is logged). Enable it only to migrate old backups: without a MAC an attacker with write access to the server could modify such files. The next Sync uploads them again in the new format, because their size no longer matches.

Development

git config core.hooksPath .githooks   # once: pre-commit hook
sh scripts/check.sh                   # gofmt, vet, golangci-lint, tests, doc pairs
go test -run TestSyncDownloadRoundTrip -v ./...

The tests start an in-process SSH/SFTP server, so no external server is needed.

See CONTRIBUTING.md for pull request guidelines.

Security

Do not commit SSH keys, passwords, or real host names. See SECURITY.md for reporting vulnerabilities and design notes.

License

MIT License — Copyright (c) Jan Neuhaus / VAYA Consulting. See LICENSE.

Documentation

Overview

Package sshcommands provides SSH/SFTP operations (upload, list, delete, download, fetch host key) with parameter-based API, no config dependency.

Index

Constants

View Source
const (
	DefaultKeepAliveInterval  = 15 * time.Second
	DefaultKeepAliveMaxMissed = 3
)

Keepalive defaults: a request every DefaultKeepAliveInterval; the connection is closed after DefaultKeepAliveMaxMissed unanswered requests.

View Source
const DefaultSSHPort = 22

DefaultSSHPort is used when Opts.Port is zero.

View Source
const DefaultSSHTimeout = 30 * time.Second

DefaultSSHTimeout is used when Opts.Timeout is zero.

View Source
const EncryptionOverhead = headerLen + macLen

EncryptionOverhead is the number of bytes added to an encrypted stream (header plus MAC).

Variables

View Source
var (
	// Version is the semantic-like version string of the library.
	// Format: Major.Minor.Patch.Build.
	Version = "1.1.0.6"

	// BuildTime is the timestamp of the build, typically injected via -ldflags
	// at build time.
	BuildTime = "2026-11-04 12:24:43"
)
View Source
var ErrDecryptIntegrity = errors.New("encrypted data failed integrity check: wrong AES password, corrupted or renamed file")

ErrDecryptIntegrity is returned when an encrypted file fails its integrity check (wrong AES password, corrupted/modified data or renamed file).

View Source
var ErrDownloadTooLarge = errors.New("remote file exceeds DownloadOptions.MaxSize")

ErrDownloadTooLarge is returned when a remote file exceeds DownloadOptions.MaxSize.

View Source
var ErrNotEncrypted = errors.New("remote file is not encrypted in the current format (legacy files need DownloadOptions.AllowLegacyDecryption)")

ErrNotEncrypted is returned by Download when an AES password is given but the remote file is not in the current encrypted format.

View Source
var ErrRemoteNotConfigured = errors.New("remote not configured: set Host in Opts")

ErrRemoteNotConfigured is returned by all operations when Opts.Host is empty. Callers that treat the remote as optional can check it with errors.Is.

Functions

func AppendKnownHostsLine

func AppendKnownHostsLine(knownHostsPath, hostToken, keyLine string, log Logger) (err error)

AppendKnownHostsLine appends a host token and key line to a known_hosts file. The token must name exactly one host (see FormatKnownHostsHostToken); the key is written as "key-type base64". Parent directories are created as needed. A missing newline at the end of the existing file is added first.

func AppendServerHostKey

func AppendServerHostKey(opts *Opts, knownHostsPath string, log Logger) (written bool, err error)

AppendServerHostKey fetches the server host key (without sending credentials) and appends it to knownHostsPath when the file does not know the host yet. A host that is already listed (with any key) is left unchanged and not contacted; a changed key then fails at connect time as a mismatch. Returns written=true when a new line was added.

func Delete

func Delete(opts *Opts, remoteDir string, names []string, log Logger) error

Delete removes the given file names from remoteDir. All names are validated before connecting; names containing path components or ".." are rejected and nothing is deleted. Failed removals are collected and returned together (errors.Join) after all names were tried.

func DialKnownHosts

func DialKnownHosts(opts *Opts, kh KnownHostsOptions, log Logger) (*ssh.Client, error)

DialKnownHosts connects using KnownHostsOptions.Path for host key verification. opts.HostKey is ignored. When FetchHostKey is set and the file is missing or does not know the host yet, the server key is fetched (without sending credentials) and appended. With TrustOnMismatch a changed key replaces the host's previous entries (development only, see KnownHostsOptions).

func DialWithHostKeyCallback

func DialWithHostKeyCallback(opts *Opts, hostKeyCallback ssh.HostKeyCallback, log Logger) (*ssh.Client, error)

DialWithHostKeyCallback connects using a custom host key callback instead of opts.HostKey. opts.HostKey is ignored.

func Download

func Download(opts *Opts, pattern, destDir, remoteDir, aesPassword string, log Logger) ([]string, error)

Download downloads files matching pattern from remoteDir into destDir with default DownloadOptions. See DownloadWithOptions.

func DownloadWithOptions added in v1.1.0

func DownloadWithOptions(opts *Opts, pattern, destDir, remoteDir, aesPassword string, dopts DownloadOptions, log Logger) ([]string, error)

DownloadWithOptions downloads files matching pattern from remoteDir into destDir (required). Pattern may be a literal filename or contain wildcards (*, ?); no path components. aesPassword empty = no decryption (surrounding whitespace is ignored); with a password every file must be encrypted in the current format (ErrNotEncrypted otherwise, see AllowLegacyDecryption). Existing local files are never overwritten: the file is saved as "<name>.lokal" (or "<name>.lokal.N"). Returns the list of local paths written.

func FetchServerHostKey

func FetchServerHostKey(opts *Opts) (keyLine string, err error)

FetchServerHostKey connects to the SSH server, captures its host key and returns it as a single line ("key-type base64"). The handshake is aborted right after the key exchange, so no credentials are sent and Opts.User, KeyFile and Password are not needed. The key is NOT verified; use it only for initial setup (trust on first use).

func FormatKnownHostsHostToken

func FormatKnownHostsHostToken(host string, port int) string

FormatKnownHostsHostToken returns the host token for a known_hosts entry.

func HostKeyAlreadyPresent

func HostKeyAlreadyPresent(currentValue, newKeyLine string) (bool, error)

HostKeyAlreadyPresent returns true if newKeyLine is already among the keys in currentValue (inline " || "-separated keys or path to a known_hosts file). The check ignores host names; revoked (@revoked) and @cert-authority lines never count as present. Use a host-aware check for known_hosts files where the host matters (AppendServerHostKey does).

func MkdirAllRemote

func MkdirAllRemote(client *ssh.Client, remotePath string, log Logger) error

MkdirAllRemote creates remotePath and any missing parent directories via SFTP.

func NewSFTPClient

func NewSFTPClient(opts *Opts, log Logger) (*sftp.Client, *ssh.Client, error)

NewSFTPClient returns an SFTP client; caller must Close() both clients.

func Sync

func Sync(opts *Opts, localFiles []LocalFile, remoteDir, aesPassword string, log Logger) error

Sync uploads local files that are missing, newer or of a different size on the remote, and deletes remote files that are not in localFiles (including leftovers of aborted uploads). remoteDir is created if needed. aesPassword empty = no encryption (surrounding whitespace is ignored).

All names are validated before connecting. An empty localFiles list is rejected because it would delete every file in remoteDir; use Delete to remove files explicitly.

func UploadFileIfNewer

func UploadFileIfNewer(client *ssh.Client, localPath, remotePath string, log Logger) error

UploadFileIfNewer uploads localPath to remotePath when the remote file is missing, has a different size, or the local file is newer (compared in whole seconds). Remote parent directories are created as needed, the upload goes to a temporary name first and the remote mtime is set to the local mtime.

func ValidDownloadPattern

func ValidDownloadPattern(pattern string) bool

ValidDownloadPattern returns false if pattern contains path components (no /, \, ..).

func ValidRemoteFileName added in v1.1.0

func ValidRemoteFileName(name string) bool

ValidRemoteFileName returns false if name is empty or ".", contains path components, "..", or control characters (0x00-0x1f, 0x7f). Use for any remote or local filename that is joined to a base directory.

Types

type DownloadOptions added in v1.1.0

type DownloadOptions struct {
	// AllowLegacyDecryption decrypts files without the current format header
	// in the legacy format (written before 0.3.0, no integrity check). Only
	// enable it to migrate old backups: an attacker with write access to the
	// server can then modify such files undetected.
	AllowLegacyDecryption bool
	// MaxSize limits the size of each remote file in bytes; 0 = unlimited.
	MaxSize int64
}

DownloadOptions controls DownloadWithOptions. The zero value is the secure default used by Download.

type KnownHostsOptions

type KnownHostsOptions struct {
	// Path to the known_hosts file (required).
	Path string
	// FetchHostKey captures the server's host key (without authenticating) and
	// appends it to Path when the file is missing or does not know the host yet
	// (trust on first use).
	FetchHostKey bool
	// TrustOnMismatch on host key mismatch fetches the current server key,
	// appends it to Path, and retries the connection once. This disables the
	// protection against man-in-the-middle attacks for changed keys.
	TrustOnMismatch bool
}

KnownHostsOptions controls SSH connections that verify the server via an OpenSSH known_hosts file. opts.HostKey is ignored; Path is used instead.

type LocalFile

type LocalFile struct {
	Name    string
	Path    string
	ModTime time.Time
	Size    int64
}

LocalFile describes a local file for Sync (name, path, modtime, size).

type Logger

type Logger interface {
	Info(string, ...interface{})
	Warn(string, ...interface{})
}

Logger is optional for progress and host key mismatch messages; nil = no logging.

type Opts

type Opts struct {
	Host          string        // SSH host name or IP address (IPv6 without brackets)
	Port          int           // 0 => DefaultSSHPort (22)
	User          string        // SSH user (required)
	KeyFile       string        // path to private key (optional)
	KeyPassphrase string        // passphrase of an encrypted KeyFile (optional)
	Password      string        // plaintext password (optional); at least KeyFile or Password required
	HostKey       string        // path to known_hosts file or inline key(s), separated by " || "
	Timeout       time.Duration // limit for TCP connect, SSH handshake and SFTP start; 0 => DefaultSSHTimeout (30s)
	// KeepAliveInterval is the interval of SSH keepalive requests that detect
	// a hanging server; 0 => DefaultKeepAliveInterval, negative => disabled.
	KeepAliveInterval time.Duration
	// KeepAliveMaxMissed unanswered keepalives close the connection;
	// 0 => DefaultKeepAliveMaxMissed.
	KeepAliveMaxMissed int
}

Opts holds SSH/SFTP connection parameters.

type RemoteEntry

type RemoteEntry struct {
	Name    string
	ModTime time.Time
	Size    int64
}

RemoteEntry describes a remote file (name, modtime, size).

func List

func List(opts *Opts, remoteDir string, log Logger) ([]RemoteEntry, error)

List returns all non-directory entries in remoteDir (name, modtime, size). A missing directory yields an empty list.

Jump to

Keyboard shortcuts

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