ssh

package
v0.8.4 Latest Latest
Warning

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

Go to latest
Published: Oct 9, 2026 License: Apache-2.0 Imports: 14 Imported by: 0

Documentation

Overview

Package ssh is the OpenWatch SSH dial layer. The connectivity-check endpoint, the future scan executor, and any other operation that needs to reach a host via SSH go through this package's Dial. Plaintext credentials are decrypted by internal/credential and passed in for the duration of one dial; they never leave this layer in any log, error, or audit row.

Spec: specs/system/ssh-connectivity.spec.yaml.

Index

Constants

View Source
const (
	PreferKey      = "key"
	PreferPassword = "password" // pragma: allowlist secret
)

Auth-method preference tokens. Plain strings (not a typed enum) so the ssh package stays decoupled from connprofile — callers translate.

View Source
const (
	SudoNopasswd = "nopasswd"
	SudoPassword = "password"
)

Sudo-mode tokens for RunSudo's prefer (in) and observed (out). Plain strings — not connprofile's typed enum — so this dial-layer file stays decoupled from connprofile, exactly as PreferKey/PreferPassword do for the auth method. The values match connprofile.SudoNopasswd / connprofile.SudoPassword, so a string()/SudoMode() cast round-trips at the call site. An empty prefer/observed means "unknown — no preference".

View Source
const DefaultDialTimeout = 10 * time.Second

DefaultDialTimeout is the upper bound on connect+handshake. Callers may supply a tighter ctx deadline; can't loosen.

Spec C-01.

View Source
const ECDSAMinBits = 256

ECDSAMinBits is the lower bound for ECDSA curves. P-256 = 256 bits.

View Source
const RSAMinBits = 2048

RSAMinBits is the lower bound for RSA keys per NIST SP 800-57 (good through 2030). Keys below this size are rejected.

Variables

View Source
var (
	ErrConnect      = errors.New("ssh: tcp connect failed")
	ErrAuthFailed   = errors.New("ssh: authentication failed")
	ErrDialTimeout  = errors.New("ssh: dial timed out")
	ErrNoAuthMethod = errors.New("ssh: credential has no usable auth method")
)

Dial-layer errors. Distinct sentinels per failure mode so callers (audit emitter, connectivity-check handler) can map to specific reason strings without parsing error text.

Spec C-06.

View Source
var (
	ErrHostKeyUnknown  = errors.New("ssh: host key not in known-hosts store")
	ErrHostKeyMismatch = errors.New("ssh: host key changed since first connection")
)

Host-key verification errors.

View Source
var (
	ErrWeakKey    = errors.New("ssh: key below NIST SP 800-57 minimum strength")
	ErrInvalidKey = errors.New("ssh: private key is unparseable or malformed")
)

Key-validation errors.

Functions

func Dial

func Dial(ctx context.Context, host string, port int, cred *credential.Credential, opts DialOptions) (*ssh.Client, error)

Dial opens an SSH connection to host:port using cred. On success returns a live *ssh.Client; caller must Close it. On failure returns a sentinel error from the package's error set (never raw network or crypto errors that might leak credential material).

Spec AC-01, AC-02, AC-03, AC-04, AC-06, AC-07, AC-08, AC-09, C-01, C-03.

func RunSudo

func RunSudo(
	ctx context.Context,
	sess SudoSession,
	cred *credential.Credential,
	policy SudoPolicy,
	prefer string,
	cmd string,
) (stdout []byte, exitCode int, usedFallback bool, observed string, err error)

RunSudo executes `cmd` as root via sudo. The pipeline is:

  1. Always try `sudo -n <cmd>` first. NOPASSWD hosts return exit 0 here and the function returns immediately. The credential password is NOT touched.
  2. If `sudo -n` returns non-zero AND all of the following hold — - policy.AllowCredentialPassword is true, - cred is non-nil, - cred.AuthMethod is "password" or "both", - cred.Password is non-empty — re-execute as `sudo -S -k -p ” <cmd>` with the password fed via the session's stdin pipe. `-k` invalidates the remote sudo credential cache before each attempt so a wrong password fails fast (no PAM retry counter increment, no host-side lockout).

prefer (a Sudo* token, or "" for unknown) is the host's learned sudo mode: when it is SudoPassword AND the credential can supply a password, RunSudo leads with `sudo -S` and skips the doomed `sudo -n` round-trip. Both forms are still attempted on a miss (a hint, not a lock), so a stale preference self-heals.

Returns the final stdout, the final exit code, a bool indicating whether the password fallback was used (callers aggregate this for the per-cycle audit emission), the sudo mode OBSERVED to work this call (a Sudo* token, or "" when neither form was confirmed), and any transport error. observed is set ONLY on a confirmed exit-0 of a given form — a real command that exits non-zero for its own reasons never produces a (mis)observation, so callers can safely record it.

Source-inspection-friendly: the password is taken from cred.Password and passed as the `stdin` argument of RunWithStdin. It does NOT appear in the `cmd` string anywhere — see ssh_test.go AC-15.

Spec: system-ssh-connectivity v1.1.0 C-09 / C-10 / C-11 / C-12, AC-11..AC-17; system-connection-profile v1.2.0 C-07 (sudo-mode learning).

func ValidateAuthKey

func ValidateAuthKey(pem []byte, passphrase string) error

ValidateAuthKey parses an OpenSSH-format private key (PEM) and applies the NIST SP 800-57 strength check. Returns:

  • ErrInvalidKey if the PEM is unparseable
  • ErrWeakKey if the key is below the minimum strength for its algorithm
  • nil if the key is acceptable for use with a Dial

Spec AC-05, AC-10, C-02.

Types

type DialOptions

type DialOptions struct {
	Mode    Mode
	Store   KnownHostsStore
	Timeout time.Duration

	// PreferAuth, when "key" or "password", offers that auth method
	// FIRST (the other is still offered as fallback). Empty preserves the
	// historical key-first order. Callers set this from the host's
	// recorded connection profile to avoid a doomed publickey attempt on
	// a password-only host (which counts against MaxAuthTries / trips
	// fail2ban).
	PreferAuth string

	// ObservedAuth, when non-nil, receives the auth method that actually
	// authenticated ("key" | "password") after a successful dial. Callers
	// persist it so the next connection leads with it. Untouched on dial
	// failure.
	ObservedAuth *string
}

DialOptions configures one Dial. Mode + Store cover host-key verification; Timeout bounds the connect+handshake.

type KnownHostsStore

type KnownHostsStore interface {
	// Get returns the stored marshalled public key for hostname, or
	// (nil, false) if no entry exists.
	Get(hostname string) ([]byte, bool)
	// Put stores the marshalled public key for hostname. Idempotent for
	// the same key; should reject (or signal upward via error) if the
	// caller tries to overwrite a different key — the caller decides
	// whether that's allowed, not the store.
	Put(hostname string, marshalled []byte) error
}

KnownHostsStore persists (hostname → public-key fingerprint). The in-memory implementation is the default; production deploys can drop in a PG-backed store later by satisfying the same interface.

type MemoryStore

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

MemoryStore is an in-memory KnownHostsStore. Goroutine-safe.

func NewMemoryStore

func NewMemoryStore() *MemoryStore

NewMemoryStore constructs an empty MemoryStore.

func (*MemoryStore) Get

func (m *MemoryStore) Get(hostname string) ([]byte, bool)

Get implements KnownHostsStore.

func (*MemoryStore) Put

func (m *MemoryStore) Put(hostname string, marshalled []byte) error

Put implements KnownHostsStore. Overwrites any prior value for the hostname — the policy (whether a different key is acceptable) is the caller's decision, not the store's.

type Mode

type Mode int

Mode controls how the dial layer treats a server's host key.

const (
	// ModeStrict requires the server's host key to be in the known-hosts
	// store before the connection succeeds. Reject unknown.
	ModeStrict Mode = iota

	// ModeTOFU (trust-on-first-use) records the first key it sees for a
	// hostname; subsequent connections behave like Strict against that
	// stored key. A different key from the same hostname returns
	// ErrHostKeyMismatch.
	ModeTOFU
)

type SudoPolicy

type SudoPolicy struct {
	AllowCredentialPassword bool
}

SudoPolicy lets the caller (collector / discovery) inject the system policy without importing the systemconfig package. Keeps this dial- layer file policy-agnostic and trivially testable.

AllowCredentialPassword corresponds to systemconfig.SecurityConfig.AllowCredentialSudoPassword. Set to false for the v1.0.0-compatible "sudo -n only" behavior.

type SudoSession

type SudoSession interface {
	// Run is the no-stdin path. Used for the initial `sudo -n` attempt
	// (NOPASSWD: never needs a password) and for any command that is
	// not gated on sudo.
	Run(ctx context.Context, cmd string) (stdout []byte, exitCode int, err error)

	// RunWithStdin is the password-fallback path. The caller passes the
	// credential password as the stdin payload — never as part of cmd.
	// Implementations MUST send `stdin` to the remote process's stdin
	// and close the pipe before returning.
	RunWithStdin(ctx context.Context, cmd string, stdin []byte) (stdout []byte, exitCode int, err error)
}

SudoSession is the subset of an SSH session that RunSudo needs. The stdin pipe path is the security-critical method: it lets RunSudo deliver the credential password to sudo without putting it in the remote process argv.

Both collector.SSHSession and discovery.SSHSession satisfy this interface once their RunWithStdin methods are added.

Jump to

Keyboard shortcuts

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