Documentation
¶
Overview ¶
Package sshremote stores per-remote SSH identity material (private keys and passphrases) in the OS keychain, and generates fresh per-remote keypairs at onboarding. See project_plans/ssh-remote-workspaces/implementation/plan.md Epic 3.2 for the design this package implements.
Index ¶
- Variables
- func IsHostKeyMismatch(err error) bool
- func RemoteApprovalSocketPath(basePath, stableSessionID string) string
- type ErrHostKeyMismatch
- type ErrUnknownHostKey
- type GeneratedIdentity
- type HealthEventPublisher
- type IdentityKind
- type KeyStore
- func (ks *KeyStore) DeleteIdentity(ctx context.Context, remoteName string) error
- func (ks *KeyStore) GenerateAndStoreIdentity(ctx context.Context, remoteName string) (GeneratedIdentity, error)
- func (ks *KeyStore) GenerateOrDescribeIdentity(ctx context.Context, remoteName string) (GeneratedIdentity, error)
- func (ks *KeyStore) GetIdentity(ctx context.Context, remoteName string) (IdentityKind, []byte, error)
- func (ks *KeyStore) SetIdentity(ctx context.Context, remoteName string, kind IdentityKind, value []byte) error
- type KeyStoreOption
- type KnownHostsStore
- type KnownHostsStoreOption
- type PermissionRequestHandler
- type RemoteApprovalRelay
- type RemoteApprovalRelayOption
- type RemoteApprovalRelayTarget
- type RemoteConnectionState
- type RemoteHealthProber
- type RemoteHealthProberOption
Constants ¶
This section is empty.
Variables ¶
var ErrIdentityNotFound = keyring.ErrNotFound
ErrIdentityNotFound is returned by GetIdentity/DeleteIdentity when no identity is stored for the given remote name. It is go-keyring's ErrNotFound re-exported under this package so callers can errors.Is against a name that doesn't leak the underlying keyring library.
var HostKeyFingerprint = tmux.HostKeyFingerprint
HostKeyFingerprint is re-exported from session/tmux for the same reason: one fingerprint-computation implementation (ssh.FingerprintSHA256, OpenSSH's default "SHA256:..." display format), not two that could drift.
Functions ¶
func IsHostKeyMismatch ¶
IsHostKeyMismatch reports whether err represents knownhosts' key-mismatch signal: a *knownhosts.KeyError with a non-empty Want (per KeyError's own doc comment, Want non-empty means "there was a mismatch, which can signify a MITM attack," as opposed to Want empty, which means the host is simply unknown -- see ErrUnknownHostKey). errors.As unwraps err first, so this also matches an *ErrHostKeyMismatch (whose Unwrap returns the underlying *knownhosts.KeyError) as well as the raw, untranslated error HostKeyCallback()'s callback returns.
Verify uses this internally to decide between ErrUnknownHostKey and ErrHostKeyMismatch. It's exported so callers on the HostKeyCallback()/ tmux.SSHRunner dial path -- which, by HostKeyCallback's own design, surfaces the raw *knownhosts.KeyError rather than ErrHostKeyMismatch, see its doc comment -- can classify a dial failure identically, without a second, independently-maintained copy of this check. (A prior version of server/services/remote_service.go had exactly that second copy, checking for *ErrHostKeyMismatch on an error that could only ever be the raw *knownhosts.KeyError -- always false, so the MITM-specific error message was dead code. This helper exists so there is exactly one place that decides "is this a mismatch.")
func RemoteApprovalSocketPath ¶
RemoteApprovalSocketPath returns the remote-host Unix domain socket path convention for a session rooted at basePath on the remote host, keyed by stableSessionID (the same value server/services.ApprovalHandler. resolveSessionID correlates a relayed request back to -- see RemoteApprovalRelayTarget.StableSessionID's doc comment). Hashed rather than used verbatim in the filename: two sessions can share the exact same basePath (e.g. two SessionTypeDirectory sessions both pointed at the same remote directory), and a fixed, basePath-only filename would let the second session's `unlink-early` socket bind silently steal the first session's listener out from under it -- found in pre-ship review. A short SHA-256 prefix of the ID sidesteps filename-safety concerns a raw title-derived StableSessionID would raise (arbitrary characters, unbounded length) without needing a bespoke sanitizer.
Uses path.Join (POSIX-style forward slashes), deliberately NOT filepath.Join: basePath always names a location on the REMOTE host (a Linux/macOS SSH target, per RemoteConfig's base_path), so joining it must not switch to backslash separators when stapler-squad itself happens to be built on Windows.
Types ¶
type ErrHostKeyMismatch ¶
type ErrHostKeyMismatch struct {
// Host is the address (as passed to Verify) whose key changed.
Host string
// Fingerprint is the SHA256 fingerprint of the NEW, untrusted key that
// was presented -- not any of the previously-trusted keys.
Fingerprint string
// Err is the underlying *knownhosts.KeyError.
Err error
}
ErrHostKeyMismatch is returned by Verify when host was previously trusted under a DIFFERENT key than the one presented now -- knownhosts' own KeyError doc comment calls this out as a possible MITM signal. This is deliberately a distinct type from ErrUnknownHostKey: a caller that only checks for ErrUnknownHostKey (e.g. to decide whether to show a "trust and connect" prompt) must NOT have a key-mismatch silently fall through the same code path -- it needs its own explicit handling, and in particular must never be treated as "safe to connect."
func (*ErrHostKeyMismatch) Error ¶
func (e *ErrHostKeyMismatch) Error() string
func (*ErrHostKeyMismatch) Unwrap ¶
func (e *ErrHostKeyMismatch) Unwrap() error
type ErrUnknownHostKey ¶
type ErrUnknownHostKey = tmux.ErrUnknownHostKey
ErrUnknownHostKey is re-exported from session/tmux so callers of Verify (and RemoteService, which maps it to a structured RPC response) only ever need to errors.As against one type, whether the error came from this store's Verify directly or, transitively, from a *tmux.SSHRunner dialed with HostKeyCallback(). There's no import-cycle risk re-using it: tmux never imports sshremote (Phase 3's wiring layer, not yet written, is expected to import both from a third package rather than have tmux depend on this one -- see NewSSHRunner's doc comment).
type GeneratedIdentity ¶
type GeneratedIdentity struct {
// PrivateKeyPEM is the OpenSSH-format PEM-encoded private key, suitable
// for both KeyStore.SetIdentity and ssh.ParsePrivateKey/
// ssh.NewSignerFromKey-compatible consumption once parsed back.
PrivateKeyPEM []byte
// PublicKeyText is the bare authorized_keys-format public key line
// ("ssh-ed25519 AAAA...", no trailing newline, no options).
PublicKeyText string
// AuthorizedKeysLine is PublicKeyText prefixed with the ADR-004
// recommended command=/restrict/pty scoping options, ready to display
// to the user during remote onboarding (Phase 6, Epic 6.1) as
// copy-paste text -- not something this package installs anywhere.
AuthorizedKeysLine string
}
GeneratedIdentity is the result of generating a fresh per-remote Ed25519 SSH keypair.
func GenerateIdentity ¶
func GenerateIdentity(remoteName string) (GeneratedIdentity, error)
GenerateIdentity generates a fresh Ed25519 keypair for remoteName. Each call produces a byte-distinct keypair (crypto/rand-backed), so generating per-remote rather than reusing one key everywhere limits a single compromised key's blast radius to one remote (research/pitfalls.md §3).
type HealthEventPublisher ¶
type HealthEventPublisher interface {
// PublishRemoteHealthChanged is called synchronously on every actual
// state transition (never on a no-op "transitioned to the state it was
// already in" check) -- implementations that do I/O should not block
// the prober's watcher/liveness goroutines for long.
PublishRemoteHealthChanged(remoteName string, state, previousState RemoteConnectionState)
}
HealthEventPublisher is anything that can be notified of a remote's connection-state transition. Defined here (the consumer package) rather than importing pkg/events.EventBus/NewRemoteHealthChangedEvent directly, which would create the import cycle described on RemoteConnectionState's doc comment -- mirrors PermissionRequestHandler's identical rationale in approval_relay.go.
Unlike PermissionRequestHandler (which *server/services.ApprovalHandler already satisfies structurally with zero changes), no existing production type satisfies this interface yet: pkg/events.EventBus.Publish takes a *pkg/events.Event, not this signature. The production wiring that starts a RemoteHealthProber per configured remote (Task 6.4.1c, server/server.go -- out of scope for this change) is expected to supply a small adapter that calls events.NewRemoteHealthChangedEvent(remoteName, state, previousState) and hands the result to an *events.EventBus.Publish.
type IdentityKind ¶
type IdentityKind string
IdentityKind tags which of the two logical value kinds an identity envelope holds -- a raw private key, or a passphrase protecting one. Both kinds live under the same key namespace (keyPrefix + remote name); the tag is what lets GetIdentity tell them apart on read-back.
const ( // IdentityKindPrivateKey tags an envelope holding raw private key bytes // (e.g. an OpenSSH-format PEM block). IdentityKindPrivateKey IdentityKind = "private_key" // IdentityKindPassphrase tags an envelope holding a passphrase that // protects an encrypted private key. IdentityKindPassphrase IdentityKind = "passphrase" )
type KeyStore ¶
type KeyStore struct {
// contains filtered or unexported fields
}
KeyStore stores SSH identity material (private keys and passphrases) in the OS keychain, keyed per remote name -- never in a file under ~/.stapler-squad/, and never with an on-disk fallback if the keychain is unavailable (per research/build-vs-buy.md §3: fail loud instead).
func NewKeyStore ¶
func NewKeyStore(opts ...KeyStoreOption) *KeyStore
NewKeyStore constructs a KeyStore.
func (*KeyStore) DeleteIdentity ¶
DeleteIdentity removes the stored identity for remoteName. Returns an error wrapping ErrIdentityNotFound if nothing was stored.
func (*KeyStore) GenerateAndStoreIdentity ¶
func (ks *KeyStore) GenerateAndStoreIdentity(ctx context.Context, remoteName string) (GeneratedIdentity, error)
GenerateAndStoreIdentity generates a fresh Ed25519 keypair for remoteName, stores the private key via KeyStore.SetIdentity, and returns the full GeneratedIdentity (including the public key text and recommended authorized_keys line) for the onboarding flow to display.
func (*KeyStore) GenerateOrDescribeIdentity ¶
func (ks *KeyStore) GenerateOrDescribeIdentity(ctx context.Context, remoteName string) (GeneratedIdentity, error)
GenerateOrDescribeIdentity is the idempotent entry point the Add Remote form's "Test connection" flow (ssh-remote-workspaces Phase 6, Epic 6.1) calls via RemoteService.GenerateRemoteIdentity: if remoteName already has a stored identity, its public key text / authorized_keys line are reconstructed from the stored private key instead of generating (and thus silently rotating) a new keypair -- a user who clicks "Test connection" more than once for the same not-yet-saved remote name must keep seeing identical key material, since that's the line they were told to paste onto the remote host. Falls through to GenerateAndStoreIdentity when nothing is stored yet, or when the stored envelope can't be parsed as a private key (treated the same as "nothing usable stored").
The check (GetIdentity) and the write (GenerateAndStoreIdentity) are held under ks.generateOrDescribeMu as a single atomic sequence: without it, two concurrent calls for the same brand-new remote name (e.g. a rapid double-click on "Test connection") could both miss the GetIdentity check before either has stored anything, then both generate and store -- silently producing two different keypairs, with the second overwriting the first and leaving the caller who received the first's response holding a key that's no longer what's actually stored.
func (*KeyStore) GetIdentity ¶
func (ks *KeyStore) GetIdentity(ctx context.Context, remoteName string) (IdentityKind, []byte, error)
GetIdentity returns the stored identity kind and value for remoteName. Returns an error wrapping ErrIdentityNotFound if nothing is stored.
func (*KeyStore) SetIdentity ¶
func (ks *KeyStore) SetIdentity(ctx context.Context, remoteName string, kind IdentityKind, value []byte) error
SetIdentity stores value (either raw private key bytes or a passphrase, per kind) for remoteName in the OS keychain, tagged with kind so GetIdentity can distinguish the two on read-back.
type KeyStoreOption ¶
type KeyStoreOption func(*KeyStore)
KeyStoreOption configures a KeyStore at construction time.
type KnownHostsStore ¶
type KnownHostsStore struct {
// contains filtered or unexported fields
}
KnownHostsStore is a file-backed, app-managed known_hosts-equivalent store for TOFU (trust-on-first-use) SSH host-key decisions, using golang.org/x/crypto/ssh/knownhosts' standard file format (knownhosts.New()/knownhosts.Line()) so the backing file stays inspectable with ordinary SSH tooling even though it's never read by a real ssh(1)/ sshd(8) process.
func NewKnownHostsStore ¶
func NewKnownHostsStore(opts ...KnownHostsStoreOption) (*KnownHostsStore, error)
NewKnownHostsStore constructs a KnownHostsStore backed by "<config dir>/ssh_known_hosts", where config dir is resolved via config.GetConfigDir() -- the same test-mode / named-instance / STAPLER_SQUAD_TEST_DIR isolation every other file-backed store in this app gets (see config.GetConfigDirForDir's priority hierarchy), so tests and named instances never read or write the real ~/.stapler-squad/ssh_known_hosts.
func (*KnownHostsStore) HostKeyCallback ¶
func (s *KnownHostsStore) HostKeyCallback() ssh.HostKeyCallback
HostKeyCallback returns an ssh.HostKeyCallback suitable for ssh.ClientConfig.HostKeyCallback (and, in particular, for tmux.NewSSHRunner's config argument). It returns the RAW knownhosts error on failure -- deliberately NOT pre-translated into ErrUnknownHostKey/ ErrHostKeyMismatch -- so tmux.NewSSHRunner's own wrapHostKeyCallback (which every SSHRunner already wraps its configured callback in) is the single place that performs that translation for the real-dial path, exactly mirroring what Verify does for the direct-call path above.
func (*KnownHostsStore) IsHostTrusted ¶
func (s *KnownHostsStore) IsHostTrusted(host string) (bool, error)
IsHostTrusted reports whether ANY key is currently trusted for host, independent of which key it is. Exists for callers that need to enforce "TOFU already happened for this host" as a precondition WITHOUT holding a candidate key to Verify against -- server/services.RemoteService. CreateRemote is the motivating case: its contract deliberately never dials the remote itself (see its own doc comment), so it never has a real key on hand, yet review found it was saving a RemoteConfig with no server-side check that TestRemoteConnection/TrustRemoteHostKey ever ran, relying entirely on frontend flow discipline.
Implemented by probing checkHostKey with a freshly generated, never- persisted-anywhere throwaway key: IsHostKeyMismatch(err) on the result means an entry exists for host (just not matching the probe key, which is guaranteed since nothing else could ever hold it) -- this is the only way to ask "does the knownhosts.New callback have ANY entry for host" without already knowing the real key, since golang.org/x/crypto/ssh/knownhosts exposes no direct "has host" query and checkHostKey/Verify are both inherently key-comparison operations.
func (*KnownHostsStore) Trust ¶
func (s *KnownHostsStore) Trust(host string, key ssh.PublicKey) error
Trust records key as the trusted host key for host, persisting it via an atomic write (temp file + rename) to the backing file. Any existing entries for host are replaced, not merely appended to: this store models "the app's current trust decision per host" (one trusted key at a time), not OpenSSH's own known_hosts semantics of accumulating every key ever seen -- replacing stale entries closes the rollback-attack window a purely additive store would otherwise leave open after a legitimate key rotation (an old, now-revoked key would otherwise keep verifying successfully forever).
func (*KnownHostsStore) Verify ¶
func (s *KnownHostsStore) Verify(host string, key ssh.PublicKey) error
Verify checks key against the trust decision on file for host (a bare hostname or "host:port" address). Returns:
- nil if host+key was previously Trust()ed.
- *ErrUnknownHostKey if host has never been seen before.
- *ErrHostKeyMismatch if host WAS previously trusted, but under a DIFFERENT key than the one presented now -- the actual MITM-relevant case, deliberately a distinct error from ErrUnknownHostKey so a caller can't silently reuse the same "go ahead and trust it" flow for both.
type KnownHostsStoreOption ¶
type KnownHostsStoreOption func(*KnownHostsStore)
KnownHostsStoreOption configures a KnownHostsStore at construction time.
type PermissionRequestHandler ¶
type PermissionRequestHandler interface {
HandlePermissionRequest(w http.ResponseWriter, r *http.Request)
}
PermissionRequestHandler is anything that can process a raw PermissionRequest hook payload the same way server/services.ApprovalHandler.HandlePermissionRequest does -- defined here (the consumer package) rather than imported from server/services, which would create an import cycle (server/services already imports session/sshremote for KeyStore/KnownHostsStore). *server/services. ApprovalHandler already has exactly this method signature and satisfies this interface structurally, with zero changes there.
type RemoteApprovalRelay ¶
type RemoteApprovalRelay struct {
// contains filtered or unexported fields
}
RemoteApprovalRelay reads approval-request payloads a remote agent process writes to a fixed Unix domain socket on the remote host (RemoteApprovalSocketPath) via a direct-streamlocal@openssh.com channel dialed over the SAME pooled *ssh.Client the session's terminal stream already uses, and drives them through PermissionRequestHandler exactly the way Claude Code's real HTTP PermissionRequest hook does -- see ADR-003 (project_plans/ssh-remote-workspaces/decisions/) for why this reuses the existing multiplexed SSH connection instead of a reverse tunnel or a third-party tunneling library, and its addendum for why the forwarding target changed from Epic 5.1's original session.ExternalApprovalMonitor to this handler-based design.
Unlike Epic 5.1's original design (request-direction-only, closing the connection immediately after decoding), this merges the request and response into a single blocking round trip: handleConnection decodes the payload, builds a synthetic *http.Request from its raw bytes, calls PermissionRequestHandler.HandlePermissionRequest -- which BLOCKS until a human decision is made or its own configured timeout fires, exactly as it does for a real local HTTP hook -- and writes the resulting response body back onto the SAME connection before closing it, so the remote-side hook script (server/services.remoteApprovalHookCommand's socat pipeline) gets the identical bytes curl's stdout would have gotten locally. This subsumes what was originally planned as a separate Epic 5.3 response- delivery step: merging request+response into one blocking round trip is simpler than two independent half-duplex pieces and was never separately buildable once PermissionRequestHandler (not ExternalApprovalMonitor) was identified as the correct target.
Known v1 constraint (not addressed here): the remote-host socket path is fixed per session, so only one approval can be in flight per remote session at a time -- handleConnection blocks the poll loop for the duration of the human decision, and a second concurrent request has nowhere to connect until the first completes. Acceptable per the original plan design; not solved by this change.
RemoteApprovalRelay never dials the SSH connection pool itself -- it only subscribes (via pool.Subscribe) to whichever *ssh.Client is CURRENTLY pooled for remoteName. Some other component (terminal streaming, per Phase 4/session/tmux.SSHRunner) is responsible for establishing and redialing that connection; decoupling the relay from dial config/ credentials entirely means it has nothing more to configure than "which pooled connection" and "which remote-side socket."
func NewRemoteApprovalRelay ¶
func NewRemoteApprovalRelay( pool *tmux.SSHClientPool, handler PermissionRequestHandler, target RemoteApprovalRelayTarget, opts ...RemoteApprovalRelayOption, ) (*RemoteApprovalRelay, error)
NewRemoteApprovalRelay constructs a RemoteApprovalRelay for a single remote session: pool is the shared SSH connection pool target.RemoteName is dialed under; handler is what every relayed request is driven through (production callers pass a *server/services.ApprovalHandler, which satisfies PermissionRequestHandler structurally). See RemoteApprovalRelayTarget's field docs for the rest.
A fresh, random bearer credential is minted immediately (see BearerToken) -- callers wiring hook injection (Epic 5.2) read it back via BearerToken to embed in the generated hook command.
func (*RemoteApprovalRelay) BearerToken ¶
func (r *RemoteApprovalRelay) BearerToken() (token string, expiresAt time.Time)
BearerToken returns the relay's current bearer credential and its expiry. Epic 5.2's hook injection uses this to embed the token in the remote agent's generated hook command so its payload passes verifyToken.
func (*RemoteApprovalRelay) RotateToken ¶
func (r *RemoteApprovalRelay) RotateToken() error
RotateToken replaces the relay's current bearer credential with a freshly generated one, valid for another defaultBearerTokenTTL. Payloads bearing the OLD token are rejected immediately after this call returns -- a caller that rotates must also re-inject the new token (via BearerToken) into any hook command not yet run on the remote side.
func (*RemoteApprovalRelay) Start ¶
func (r *RemoteApprovalRelay) Start(ctx context.Context)
Start begins the relay's poll loop: it subscribes to pool's reconnect signal for remoteName (Task 5.1.2a) and repeatedly dials the remote-side Unix socket over whichever *ssh.Client is currently pooled, reading and forwarding relayed approval payloads until ctx is done or Stop is called. Safe to call at most once per RemoteApprovalRelay; later calls are a no-op.
func (*RemoteApprovalRelay) Stop ¶
func (r *RemoteApprovalRelay) Stop()
Stop halts the relay's poll loop and unsubscribes from the pool's reconnect signal, waiting for both background goroutines to exit. Safe to call multiple times, or without a prior Start.
type RemoteApprovalRelayOption ¶
type RemoteApprovalRelayOption func(*RemoteApprovalRelay)
RemoteApprovalRelayOption configures a RemoteApprovalRelay at construction time.
type RemoteApprovalRelayTarget ¶
type RemoteApprovalRelayTarget struct {
// RemoteName is the SSHClientPool key this relay's channel is dialed
// under (session/tmux.SSHClientPool). Must match the same name the
// session's own tmux.SSHRunner/tmux.SSHTarget was constructed with, so
// this relay shares the SAME pooled *ssh.Client the terminal stream
// uses (ADR-003) rather than dialing an unrelated connection.
RemoteName string
// BasePath is the session's remote-host working-directory root -- i.e.
// its worktree path on the remote host (session.Instance.
// GetEffectiveRootDir()), NOT the remote's shared config.RemoteConfig.
// BasePath. RemoteApprovalSocketPath is derived from it, so the fixed
// socket this relay reads from is scoped to ONE session, not shared
// across every session on the same remote (which would make the "one
// approval in flight at a time" constraint apply across unrelated
// sessions instead of within a single one).
BasePath string
// StableSessionID is written into the synthetic HTTP request's
// X-CS-Session-ID header before it's handed to PermissionRequestHandler
// -- must be the SAME stable ID server/services.ApprovalHandler.
// resolveSessionID resolves this session to locally
// (session.Instance.GetStableID(): UUID, falling back to Title), or the
// handler silently fails to correlate the relayed request with the
// right session's approval UI/notifications. Renamed from Epic 5.1's
// "SessionKey" (a session.ExternalApprovalMonitor lookup key that no
// longer applies once this relay stopped forwarding into that
// subsystem -- see ADR-003's addendum) to name what this value is
// actually used for now.
StableSessionID string
// Title is used only for this relay's own log messages; the local
// approval UI's session title comes from PermissionRequestHandler's own
// session lookup (ApprovalHandler.resolveSessionName), not from here.
Title string
}
RemoteApprovalRelayTarget bundles the four caller-supplied identifiers NewRemoteApprovalRelay needs. Deliberately a struct, not four adjacent string parameters: RemoteName/BasePath/StableSessionID/Title are independently meaningful, same-typed strings a caller could silently transpose at a call site with no compiler error (e.g. passing StableSessionID where Title is expected still compiles) -- exactly what the `primitive-obsession-checklist` skill exists to catch. Field names carry the disambiguation a positional four-string parameter list can't.
type RemoteConnectionState ¶
type RemoteConnectionState string
RemoteConnectionState identifies where a configured remote's SSH connection currently stands, as tracked by RemoteHealthProber. No prior definition of this concept exists anywhere in the codebase (grepped session/, server/, pkg/ for RemoteConnectionState/ConnectionState before adding this) -- defined here, alongside the prober that owns the state machine, rather than in pkg/events: pkg/events imports session, and session (session/instance.go) already imports session/sshremote, so session/sshremote importing pkg/events back would be an import cycle (confirmed via `go list -deps`). This mirrors why PermissionRequestHandler is defined in THIS package rather than imported from server/services (see approval_relay.go's doc comment) -- same cycle shape, one layer over. pkg/events references this type instead (session/sshremote has no dependency back on pkg/events or session, so that direction is cycle-free) -- the same shape session/detection.DetectedStatus already has via pkg/events.Event.DetectedStatusTyped.
const ( // RemoteConnectionStateDisconnected is a RemoteHealthProber's initial // state, and the state entered when either (a) the pooled *ssh.Client's // Wait() returns (a hard, push-driven disconnect signal), or (b) a // reconnect attempt has not yet succeeded. RemoteConnectionStateDisconnected RemoteConnectionState = "disconnected" // RemoteConnectionStateConnected is entered once the pooled *ssh.Client // is live and the most recent liveness check succeeded. RemoteConnectionStateConnected RemoteConnectionState = "connected" // RemoteConnectionStateReconnecting is a soft, between-hard-disconnects // signal: the pooled *ssh.Client's Wait() has not returned (no hard // teardown observed), but the most recent periodic liveness check (a // trivial no-op remote command) failed -- e.g. a stalled or degraded // network path, or the remote host rejecting new channels while the // underlying transport connection is still technically up. RemoteConnectionStateReconnecting RemoteConnectionState = "reconnecting" )
type RemoteHealthProber ¶
type RemoteHealthProber struct {
// contains filtered or unexported fields
}
RemoteHealthProber tracks one configured remote's SSH connection health and publishes push-driven connected/reconnecting/disconnected state transitions via HealthEventPublisher, per Epic 6.4 / Story 6.4.1.
It never dials a dedicated connection of its own: runner must be constructed against the SAME SSHClientPool passed as pool (and the same SSHTarget.Name as remoteName), so both this prober's liveness checks and any session's own SSHRunner/RemoteApprovalRelay for the same remote share exactly one dialed *ssh.Client (see tmux.SSHRunner.Dial's doc comment, which names RemoteHealthProber as its reuse case). Disconnect detection is driven primarily by that shared client's Wait() -- a push/blocking signal, not a poll loop -- with a periodic lightweight liveness check (SSHRunner.Run(ctx, "", "true")) filling the gap Wait() alone can't see: a connection that hasn't hard-failed yet but also isn't currently answering commands.
func NewRemoteHealthProber ¶
func NewRemoteHealthProber( pool *tmux.SSHClientPool, runner *tmux.SSHRunner, remoteName string, publisher HealthEventPublisher, opts ...RemoteHealthProberOption, ) (*RemoteHealthProber, error)
NewRemoteHealthProber constructs a RemoteHealthProber for a single configured remote: pool is the shared SSH connection pool remoteName is dialed/pooled under; runner is used for this prober's periodic liveness checks and MUST be constructed against the same pool and the same SSHTarget.Name as remoteName (see this type's doc comment); publisher receives every actual state transition.
func (*RemoteHealthProber) Start ¶
func (p *RemoteHealthProber) Start(ctx context.Context)
Start begins the prober's two background loops: watchReconnects (the push-driven path, subscribed to pool's reconnect signal for remoteName) and runLivenessLoop (the periodic soft-degradation/reconnect-attempt path). Safe to call at most once per RemoteHealthProber; later calls are a no-op.
func (*RemoteHealthProber) State ¶
func (p *RemoteHealthProber) State() RemoteConnectionState
State returns the prober's current view of the remote's connection state. Exposed for tests and observability, mirroring SSHClientPool.RefCount's convention.
func (*RemoteHealthProber) Stop ¶
func (p *RemoteHealthProber) Stop()
Stop halts the prober's watchReconnects and runLivenessLoop goroutines and waits for both to exit. Safe to call multiple times, or without a prior Start.
Stop does NOT wait for any in-flight watchClientDeath goroutine (see its doc comment for why: it blocks on the pooled *ssh.Client's own Wait(), which has no relationship to this prober's ctx and may not return for as long as the underlying connection stays alive -- exactly the same unbounded-lifetime shape session/tmux/ssh_pool.go's own internal Client.Wait() eviction watcher already has, and for the same reason: the shared client can legitimately outlive any single consumer). Those goroutines check p.ctx.Done() before publishing, so a stale post-Stop disconnect signal is dropped rather than delivered.
type RemoteHealthProberOption ¶
type RemoteHealthProberOption func(*RemoteHealthProber)
RemoteHealthProberOption configures a RemoteHealthProber at construction time.