Documentation
¶
Overview ¶
Package sshclient connects to a managed server over SSH, the transport used both to deploy the agent and, by default, to reach its HTTP API without exposing it on a public interface (see Tunnel in tunnel.go).
Index ¶
- func Dial(cfg models.SSHConfig, passphrase string) (*ssh.Client, error)
- func DownloadFile(client *ssh.Client, url, remotePath string, mode os.FileMode) error
- func DownloadFileAs(client *ssh.Client, sudo Sudo, url, remotePath string, mode os.FileMode) error
- func ProbeSudo(client *ssh.Client, password string) error
- func Run(client *ssh.Client, cmd string) (string, error)
- func RunAs(client *ssh.Client, sudo Sudo, cmd string) (string, error)
- func UploadFile(client *ssh.Client, remotePath string, mode os.FileMode, data []byte) error
- func UploadFileAs(client *ssh.Client, sudo Sudo, remotePath string, mode os.FileMode, ...) error
- type Manager
- func (m *Manager) Close() error
- func (m *Manager) Ensure(serverID uuid.UUID, sshCfg models.SSHConfig, agentAddr string) (*TunnelClient, error)
- func (m *Manager) Get(serverID uuid.UUID) (*TunnelClient, bool)
- func (m *Manager) IsOpen(serverID uuid.UUID) bool
- func (m *Manager) Open(serverID uuid.UUID, sshCfg models.SSHConfig, agentAddr string) error
- func (m *Manager) OpenAll(servers []models.Server) map[uuid.UUID]error
- func (m *Manager) PassphraseFor(serverID uuid.UUID) string
- func (m *Manager) Reopen(serverID uuid.UUID, sshCfg models.SSHConfig, agentAddr string) error
- func (m *Manager) SetPassphrase(serverID uuid.UUID, passphrase string, applyToAll bool)
- func (m *Manager) SetSudoPassword(serverID uuid.UUID, password string, applyToAll bool)
- func (m *Manager) SudoPasswordFor(serverID uuid.UUID) string
- type PassphraseRequiredError
- type Sudo
- type SudoPasswordRequiredError
- type TunnelClient
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func Dial ¶
Dial opens an SSH connection to the server using its stored SSH config. cfg.Key is a path to a private key file on the machine running awg-admin (see models.SSHConfig), not the key body itself; cfg.KeyData, if set, is the key's raw PEM content uploaded directly into storage and takes precedence over Key. passphrase decrypts the key when it's passphrase-protected — pass "" when none is cached yet; a *PassphraseRequiredError is returned if one turns out to be needed.
Host key verification is intentionally not performed (InsecureIgnoreHostKey): these are servers the operator just typed an address for, with no pre-shared known_hosts entry, mirroring how most lightweight server management tools behave on first connect.
func DownloadFile ¶
DownloadFile runs a remote command that fetches url directly into remotePath on the host client is connected to, with the given permissions — used to deploy an AgentSource without local caching (models.AgentSource.CacheLocally == false), so the binary's bytes never pass through the machine running awg-admin. Tries curl, then wget, taking whichever *works* — not just whichever is on PATH: a curl built without HTTPS support fails an https:// URL with exit 4 (CURLE_NOT_BUILT_IN), so we fall through to wget (which normally does have TLS) rather than giving up. Downloads to a temporary path first and installs it atomically via `install -D -m` (mirroring UploadFile), so a failed/partial download never leaves a broken file at remotePath.
func DownloadFileAs ¶ added in v1.3.0
DownloadFileAs is DownloadFile with privilege escalation (see Sudo): the whole fetch-then-install pipeline runs through RunAs, so writing remotePath (e.g. /usr/local/bin/awg-agent) works for a non-root SSH user.
func ProbeSudo ¶ added in v1.3.0
ProbeSudo reports whether sudo is usable for the connected user. With an empty password it checks passwordless sudo (`sudo -n true`); with a password it validates it (`sudo -S` fed the password, after `-k` drops any cached grant so the password is actually exercised). A nil error means sudo works that way — see deploy.resolveSudo.
func Run ¶
Run executes a single command on the remote host and returns its combined stdout+stderr output.
func RunAs ¶ added in v1.3.0
RunAs runs cmd on the remote host, escalating with sudo per sudo. It wraps cmd in `sh -c` under sudo so shell operators keep working; a sudo password (Sudo.Password) is fed on stdin via `sudo -S`.
func UploadFile ¶
UploadFile writes data to remotePath on the remote host with the given permissions, creating parent directories as needed. It streams the file over the session's stdin rather than relying on SFTP/SCP being installed, since `install` ships with coreutils on essentially every Linux distro.
func UploadFileAs ¶ added in v1.3.0
func UploadFileAs(client *ssh.Client, sudo Sudo, remotePath string, mode os.FileMode, data []byte) error
UploadFileAs is UploadFile with privilege escalation (see Sudo). For a sudo password it prepends the password line to stdin so `sudo -S` consumes it and the following `install` reads the file bytes from the same /dev/stdin (sudo reads the password one byte at a time, so it doesn't over-read the file).
Types ¶
type Manager ¶
type Manager struct {
// contains filtered or unexported fields
}
Manager holds one TunnelClient per server that doesn't have mTLS configured — those reach their agent exclusively through an SSH tunnel, so awg-admin keeps the underlying SSH connection open for the lifetime of the process instead of dialing per request. Servers with mTLS configured are not tracked here; they're reached directly (see models.AgentTLS).
func NewManager ¶
func NewManager() *Manager
NewManager returns an empty Manager. Call Open for each server to populate it (typically done once at startup via OpenAll).
func (*Manager) Ensure ¶
func (m *Manager) Ensure(serverID uuid.UUID, sshCfg models.SSHConfig, agentAddr string) (*TunnelClient, error)
Ensure returns a working tunnel client for serverID, lazily (re)dialing one if none is cached yet or the cached one's underlying SSH connection has actually died — e.g. because the remote agent's host rebooted, sshd restarted, or the connection was silently dropped by the network. Open only ever replaces an entry on a successful *new* dial (see Open), so without this, a tunnel that died after being opened would stay cached forever and every call through it would keep failing the same way.
func (*Manager) Get ¶
func (m *Manager) Get(serverID uuid.UUID) (*TunnelClient, bool)
Get returns the tunnel client for serverID, if one is open.
func (*Manager) IsOpen ¶
IsOpen reports whether serverID currently has a live SSH tunnel. Servers reached directly via mTLS are never tracked here (see OpenAll), so this always reports false for them regardless of their actual reachability.
func (*Manager) Open ¶
Open dials sshCfg and stores the resulting tunnel client under serverID, closing any previously open tunnel for that server first.
func (*Manager) OpenAll ¶
OpenAll dials an SSH tunnel for every server in servers that has a valid SSH config and no mTLS configured, replacing any existing tunnel for that server ID. It returns the per-server dial errors (if any) keyed by server ID rather than failing outright, since one unreachable server shouldn't block startup for the rest.
func (*Manager) PassphraseFor ¶
PassphraseFor returns the cached passphrase for serverID, falling back to the "use for all connections" passphrase (if any) when none was cached specifically for this server.
func (*Manager) Reopen ¶
Reopen (re)dials serverID's tunnel like Open, but deduplicates concurrent callers: when several goroutines reopen the same server at once — the dashboard fires metrics + agent-status + host-info together, and they all fail on one dying/shared tunnel — only ONE actually dials (and closes the stale connection); the rest wait and share its result. Without this, each caller's Open would dial a fresh connection and Close the previous one, yanking it out from under the others' in-flight channel opens — which surfaces as `ssh: unexpected packet in response to channel open` (see internal/service.tunnelDropped). Use it for the hot (re)dial paths (Ensure, callAgent's retry); Open stays for the one-shot startup/deploy paths.
func (*Manager) SetPassphrase ¶
SetPassphrase caches passphrase as serverID's SSH key passphrase, used by every future (re)connect attempt for that server without prompting the user again. When applyToAll is true, passphrase also becomes the fallback tried for any other server's key that turns out to need one.
func (*Manager) SetSudoPassword ¶ added in v1.3.0
SetSudoPassword caches password as serverID's sudo password for every future deploy to that server (see Sudo). When applyToAll is true it also becomes the fallback tried for any other server whose host turns out to need a sudo password — mirroring SetPassphrase.
func (*Manager) SudoPasswordFor ¶ added in v1.3.0
SudoPasswordFor returns the cached sudo password for serverID, falling back to the "use for all connections" one when none was cached for this server. Empty means none is known yet — the deploy first tries passwordless sudo and, if that fails, surfaces a SudoPasswordRequiredError to prompt.
type PassphraseRequiredError ¶
type PassphraseRequiredError struct {
Err error
}
PassphraseRequiredError indicates the SSH private key (uploaded KeyData or a Key file) is passphrase-protected and either no passphrase was supplied or the one supplied was wrong. Its Error() message carries the sshPassphraseMarker sentinel so callers across process boundaries (HTTP JSON error bodies, Wails-bound method errors serialized to plain strings) can still detect it with a substring check and prompt the user instead of showing a generic connection-failure message.
func (*PassphraseRequiredError) Error ¶
func (e *PassphraseRequiredError) Error() string
func (*PassphraseRequiredError) Unwrap ¶
func (e *PassphraseRequiredError) Unwrap() error
type Sudo ¶ added in v1.3.0
Sudo controls privilege escalation for a deploy's remote commands. The zero value (Enabled false) runs each command unchanged — used when the SSH user is root. When Enabled, commands run through sudo: non-interactively (`sudo -n …`) when Password is empty (the host has passwordless sudo), else with Password fed to `sudo -S` on stdin. deploy.resolveSudo builds it after probing the host (see ProbeSudo).
type SudoPasswordRequiredError ¶ added in v1.3.0
type SudoPasswordRequiredError struct {
Err error
}
SudoPasswordRequiredError indicates the deploy's SSH user isn't root and so needs sudo, but the host asks for a sudo password and none is cached (Err nil) or the cached/supplied one was rejected (Err set). Like PassphraseRequiredError its message carries a stable marker so the frontend recognizes it across the HTTP-body / Wails-error string boundary and prompts for the sudo password instead of showing a generic deploy failure.
func (*SudoPasswordRequiredError) Error ¶ added in v1.3.0
func (e *SudoPasswordRequiredError) Error() string
func (*SudoPasswordRequiredError) Unwrap ¶ added in v1.3.0
func (e *SudoPasswordRequiredError) Unwrap() error
type TunnelClient ¶
TunnelClient is an *http.Client whose connections to the agent are carried over an existing SSH connection, plus the underlying SSH client so the caller can close it once done.
func NewTunnelClient ¶
func NewTunnelClient(client *ssh.Client, agentAddr string) *TunnelClient
NewTunnelClient dials sshCfg and returns an http.Client that reaches agentAddr (typically the agent's loopback address, e.g. 127.0.0.1:8080, on the remote server) through that SSH connection — no local listening port or separate port-forward process is needed since ssh.Client.Dial opens the remote-side connection directly per request.
func (*TunnelClient) Alive ¶
func (t *TunnelClient) Alive() bool
Alive reports whether the underlying SSH connection is still up, purely from in-memory state — it never does network I/O itself.
An earlier version of this method round-tripped a throwaway global request (ssh.Client.SendRequest) to probe the connection live. That's unsafe: golang.org/x/crypto/ssh's mux.SendRequest drains its globalResponses channel in a `select { case <-ch: default: break }` loop before sending — once the connection has actually died, that channel is left permanently closed, and reading from a closed channel never blocks and is always "ready", so the select's case fires every iteration and `default` (the only exit) is never reached: SendRequest livelocks the calling goroutine forever instead of returning an error. So calling it *after* a connection is already known-dead — exactly the case Ensure needs to detect — could hang Ensure permanently rather than letting it reconnect.
Instead, NewTunnelClient starts a background goroutine that blocks on ssh.Client.Wait() (which returns as soon as the connection's read loop errors out — clean close, EOF, or reset) and closes dead when it does. Alive just checks whether that's happened yet.
func (*TunnelClient) Close ¶
func (t *TunnelClient) Close() error
Close tears down the underlying SSH connection.