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
- Variables
- func Dial(ctx context.Context, host string, port int, cred *credential.Credential, ...) (*ssh.Client, error)
- func RunSudo(ctx context.Context, sess SudoSession, cred *credential.Credential, ...) (stdout []byte, exitCode int, usedFallback bool, observed string, err error)
- func ValidateAuthKey(pem []byte, passphrase string) error
- type DialOptions
- type KnownHostsStore
- type MemoryStore
- type Mode
- type SudoPolicy
- type SudoSession
Constants ¶
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.
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".
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.
const ECDSAMinBits = 256
ECDSAMinBits is the lower bound for ECDSA curves. P-256 = 256 bits.
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 ¶
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.
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.
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:
- Always try `sudo -n <cmd>` first. NOPASSWD hosts return exit 0 here and the function returns immediately. The credential password is NOT touched.
- 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 ¶
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.
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.