Files
haul/internal/sshx/sshx.go
T
bsncubed 6b52e83bdc Initial commit: haul — desktop SFTP client
A two-pane local/remote file browser over SSH, split out of dial so file
transfer lives in its own tool. The two share ~/.ssh/config as their base.

- internal/sshconf: ~/.ssh/config parsing, for the saved-host list.
- internal/sshx: dialling and auth — agent, keys, password, host-key
  verification, ProxyJump.
- internal/vfs: the FS interface both sides are driven through, implemented
  over the local disk and over SFTP.
- internal/xfer: recursive copy engine with progress reporting, cancellation,
  conflict policy, and resume of partial transfers.
- internal/ui: the Fyne GUI — connect window (saved hosts plus quick connect)
  and the two-pane browser session.

Build with `make build`; the Makefile locates the Go toolchain at ~/.local/go,
which is deliberately kept off PATH. Cross-compiling needs a per-target C
toolchain because Fyne is cgo, so `make dist` builds the native binary only —
the alternatives are documented in the Makefile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 23:21:21 +10:00

476 lines
15 KiB
Go

// Package sshx establishes the in-process SSH connection the SFTP browser runs
// over. haul is a GUI, so it never hands the terminal to the system ssh client;
// it opens the transport itself.
//
// There are two ways in. Connect resolves a ~/.ssh/config alias by asking the
// system ssh client via `ssh -G <alias>`, which prints the fully resolved
// effective configuration (HostName, User, Port, IdentityFile, ProxyJump,
// known-hosts files, …) — so ssh_config semantics are never re-implemented here.
// ConnectParams takes an explicit target instead, for quick connect. Both then
// authenticate with the ssh-agent, any identity files, and optionally a
// password; verify the host key against known_hosts; and dial directly or
// through a ProxyJump chain.
package sshx
import (
"bufio"
"errors"
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/agent"
"golang.org/x/crypto/ssh/knownhosts"
)
// Params is the subset of resolved ssh_config that dial needs to connect.
type Params struct {
Alias string
HostName string
User string
Port string
IdentityFiles []string
ProxyJump string // "" or an alias/host (possibly comma-separated)
KnownHostsFile []string
StrictHostKey string // "yes" | "no" | "accept-new" | "ask"
IdentityAgent string // socket path, or "" / "none"
IdentitiesOnly bool // only offer the configured identity files
}
// Options tunes a connection attempt. Every callback may block on user input:
// they are invoked from the connecting goroutine, never from the UI thread.
type Options struct {
// Passphrase is called to unlock an encrypted identity file that the agent
// doesn't already hold. It may return an empty slice to skip the key.
Passphrase func(keyfile string) ([]byte, error)
// Password, if set, enables password (and keyboard-interactive)
// authentication as a fallback after any keys have been offered. It is the
// quick-connect path: a host typed into the GUI has no configured identity.
Password func(user, host string) ([]byte, error)
// AcceptHostKey, if set, is consulted when the server's key is not in
// known_hosts, and the key is recorded when it returns true. Without it an
// unknown host is refused outright. A *changed* key is always refused and
// never reaches this callback.
AcceptHostKey func(host string, key ssh.PublicKey) (bool, error)
// Logf, if set, receives human-readable diagnostics about which auth methods
// were assembled — printed by the caller when a connection fails.
Logf func(format string, args ...any)
}
func (o Options) logf(format string, args ...any) {
if o.Logf != nil {
o.Logf(format, args...)
}
}
// Addr returns host:port for dialing.
func (p Params) Addr() string {
port := p.Port
if port == "" {
port = "22"
}
return net.JoinHostPort(p.HostName, port)
}
const dialTimeout = 20 * time.Second
// Resolve runs `ssh -G alias` and parses the effective configuration.
func Resolve(alias string) (Params, error) {
out, err := exec.Command("ssh", "-G", alias).Output()
if err != nil {
return Params{}, fmt.Errorf("resolving ssh config for %q (is ssh installed?): %w", alias, err)
}
return parseSSHG(alias, string(out)), nil
}
// parseSSHG parses `ssh -G` key/value output. Keys are case-insensitive and
// some (identityfile, userknownhostsfile) may repeat.
func parseSSHG(alias, out string) Params {
p := Params{Alias: alias}
sc := bufio.NewScanner(strings.NewReader(out))
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" {
continue
}
key, val, ok := strings.Cut(line, " ")
if !ok {
continue
}
val = strings.TrimSpace(val)
switch strings.ToLower(key) {
case "hostname":
p.HostName = val
case "user":
p.User = val
case "port":
p.Port = val
case "identityfile":
p.IdentityFiles = append(p.IdentityFiles, expandTilde(val))
case "proxyjump":
if val != "none" {
p.ProxyJump = val
}
case "userknownhostsfile":
for _, f := range strings.Fields(val) {
p.KnownHostsFile = append(p.KnownHostsFile, expandTilde(f))
}
case "stricthostkeychecking":
p.StrictHostKey = val
case "identityagent":
p.IdentityAgent = expandTilde(val)
case "identitiesonly":
p.IdentitiesOnly = strings.EqualFold(val, "yes")
}
}
if p.HostName == "" {
p.HostName = alias
}
return p
}
// Connect opens an SSH client to the given alias, following a ProxyJump chain if
// configured. The caller is responsible for Close.
func Connect(alias string, opts Options) (*ssh.Client, error) {
p, err := Resolve(alias)
if err != nil {
return nil, err
}
return connect(p, 0, opts)
}
// ConnectParams opens an SSH client to an explicitly specified target without
// consulting ssh_config — haul's quick-connect path, where the user typed a
// host, port and username into the GUI. Auth still uses the agent and any
// IdentityFiles set on p, falling back to Options.Password.
func ConnectParams(p Params, opts Options) (*ssh.Client, error) {
if p.HostName == "" {
return nil, errors.New("no host given")
}
if p.Alias == "" {
p.Alias = p.HostName
}
return connect(p, 0, opts)
}
const maxJumps = 8
func connect(p Params, depth int, opts Options) (*ssh.Client, error) {
if depth > maxJumps {
return nil, errors.New("ProxyJump chain too deep")
}
cfg, err := clientConfig(p, opts)
if err != nil {
return nil, err
}
if p.ProxyJump == "" {
client, err := ssh.Dial("tcp", p.Addr(), cfg)
if err != nil {
return nil, fmt.Errorf("connecting to %s: %w", p.Alias, err)
}
return client, nil
}
// Dial through the jump host. Only the first hop of a comma-separated list
// is resolved here; each hop recurses, so a chain still works.
jumpAlias := strings.Split(p.ProxyJump, ",")[0]
jp, err := Resolve(jumpAlias)
if err != nil {
return nil, err
}
jumpClient, err := connect(jp, depth+1, opts)
if err != nil {
return nil, fmt.Errorf("connecting via jump host %s: %w", jumpAlias, err)
}
conn, err := jumpClient.Dial("tcp", p.Addr())
if err != nil {
jumpClient.Close()
return nil, fmt.Errorf("dialing %s through %s: %w", p.Alias, jumpAlias, err)
}
ncc, chans, reqs, err := ssh.NewClientConn(conn, p.Addr(), cfg)
if err != nil {
jumpClient.Close()
return nil, fmt.Errorf("ssh handshake with %s: %w", p.Alias, err)
}
return ssh.NewClient(ncc, chans, reqs), nil
}
func clientConfig(p Params, opts Options) (*ssh.ClientConfig, error) {
user := p.User
if user == "" {
if u := os.Getenv("USER"); u != "" {
user = u
}
}
auth, err := authMethods(p, opts)
if err != nil {
return nil, err
}
hkCallback, err := hostKeyCallback(p, opts)
if err != nil {
return nil, err
}
return &ssh.ClientConfig{
User: user,
Auth: auth,
HostKeyCallback: hkCallback,
Timeout: dialTimeout,
}, nil
}
// authMethods builds the public-key auth, mirroring how the system ssh client
// chooses keys so it stays under the server's MaxAuthTries limit:
//
// - It offers only the keys the config's IdentityFile lines point to, not
// every key in the agent. Offering the whole agent plus all default
// identity files can exceed MaxAuthTries (typically 6) and the server drops
// the connection before the right key is tried.
// - For each configured identity, it prefers a matching signer already held
// by the agent (so passphrase-protected keys loaded via ssh-add work without
// re-prompting), then falls back to loading the key file (prompting for a
// passphrase if the key is encrypted and not in the agent).
// - If no configured identity resolves, it falls back to offering the agent's
// own keys so agent-only setups still work.
func authMethods(p Params, opts Options) ([]ssh.AuthMethod, error) {
agentSigners := dialAgent(p, opts)
byPub := map[string]ssh.Signer{}
for _, s := range agentSigners {
byPub[string(s.PublicKey().Marshal())] = s
}
opts.logf("ssh-agent: %d key(s) available", len(agentSigners))
var ordered []ssh.Signer
seen := map[string]bool{}
add := func(s ssh.Signer) {
k := string(s.PublicKey().Marshal())
if !seen[k] {
seen[k] = true
ordered = append(ordered, s)
}
}
for _, f := range p.IdentityFiles {
// Prefer the agent's copy of this identity, matched by its public key.
if pub, err := readPublicKey(f + ".pub"); err == nil {
if s, ok := byPub[string(pub.Marshal())]; ok {
opts.logf("identity %s: using agent-held key", f)
add(s)
continue
}
}
b, err := os.ReadFile(f)
if err != nil {
continue // identity file not present; skip quietly
}
signer, err := ssh.ParsePrivateKey(b)
if err != nil {
var missing *ssh.PassphraseMissingError
if errors.As(err, &missing) && opts.Passphrase != nil {
pass, perr := opts.Passphrase(f)
if perr == nil && len(pass) > 0 {
if signer, err = ssh.ParsePrivateKeyWithPassphrase(b, pass); err == nil {
opts.logf("identity %s: loaded (passphrase)", f)
add(signer)
continue
}
}
}
opts.logf("identity %s: skipped (%v)", f, err)
continue
}
opts.logf("identity %s: loaded", f)
add(signer)
}
var methods []ssh.AuthMethod
switch {
case len(ordered) > 0:
// Offer exactly the configured identities — few, correct, in order.
methods = append(methods, ssh.PublicKeys(ordered...))
case !p.IdentitiesOnly && len(agentSigners) > 0:
// No configured identity resolved; fall back to the agent's keys.
opts.logf("no configured identity resolved; offering all agent keys")
methods = append(methods, ssh.PublicKeys(agentSigners...))
}
// Password auth goes last so a working key is always preferred. Servers that
// only advertise keyboard-interactive for passwords need the second method;
// both draw on the same callback, which the GUI answers from one prompt.
if opts.Password != nil {
ask := func() (string, error) {
b, err := opts.Password(p.User, p.HostName)
return string(b), err
}
methods = append(methods, ssh.PasswordCallback(ask))
methods = append(methods, ssh.KeyboardInteractive(
func(name, instruction string, questions []string, echos []bool) ([]string, error) {
answers := make([]string, len(questions))
pass, err := ask()
if err != nil {
return nil, err
}
for i := range questions {
answers[i] = pass
}
return answers, nil
}))
opts.logf("password auth available as a fallback")
}
if len(methods) == 0 {
return nil, errors.New("no usable SSH key: load your key into ssh-agent (ssh-add) " +
"or ensure the configured IdentityFile exists")
}
return methods, nil
}
// dialAgent connects to the ssh-agent and returns its signers, or nil if the
// agent is unreachable.
func dialAgent(p Params, opts Options) []ssh.Signer {
sock := p.IdentityAgent
if sock == "" || sock == "SSH_AUTH_SOCK" {
sock = os.Getenv("SSH_AUTH_SOCK")
}
if sock == "" || sock == "none" {
opts.logf("ssh-agent: not configured (SSH_AUTH_SOCK unset)")
return nil
}
conn, err := net.Dial("unix", sock)
if err != nil {
opts.logf("ssh-agent: unreachable (%v)", err)
return nil
}
signers, err := agent.NewClient(conn).Signers()
if err != nil {
opts.logf("ssh-agent: error listing keys (%v)", err)
return nil
}
return signers
}
// readPublicKey reads and parses an OpenSSH ".pub" file.
func readPublicKey(path string) (ssh.PublicKey, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
pub, _, _, _, err := ssh.ParseAuthorizedKey(b)
return pub, err
}
// hostKeyCallback verifies the server against known_hosts. If strict checking
// is disabled it accepts any key. An unknown host is offered to
// Options.AcceptHostKey (and recorded if accepted) — quick connect has to be
// able to reach a host that has never been ssh'd to. A key that is present but
// *different* is always fatal: that is the attack this check exists for, and no
// prompt should make it dismissable.
func hostKeyCallback(p Params, opts Options) (ssh.HostKeyCallback, error) {
if strings.EqualFold(p.StrictHostKey, "no") {
return ssh.InsecureIgnoreHostKey(), nil //nolint:gosec — user opted out via config
}
files := p.KnownHostsFile
if len(files) == 0 {
if home, err := os.UserHomeDir(); err == nil {
files = []string{filepath.Join(home, ".ssh", "known_hosts")}
}
}
if len(files) == 0 {
return nil, errors.New("cannot locate a known_hosts file")
}
var existing []string
for _, f := range files {
if _, err := os.Stat(f); err == nil {
existing = append(existing, f)
}
}
// New keys are appended to the first configured file, which is the one ssh
// itself would write to.
writeTo := files[0]
if len(existing) == 0 {
if opts.AcceptHostKey == nil {
return nil, errors.New("no known_hosts file found; ssh to this host once first to record its key")
}
// Nothing recorded yet: every host is unknown, so go straight to the prompt.
return func(hostname string, remote net.Addr, key ssh.PublicKey) error {
return acceptAndRecord(writeTo, hostname, key, opts)
}, nil
}
known, err := knownhosts.New(existing...)
if err != nil {
return nil, fmt.Errorf("reading known_hosts: %w", err)
}
return func(hostname string, remote net.Addr, key ssh.PublicKey) error {
err := known(hostname, remote, key)
if err == nil {
return nil
}
var ke *knownhosts.KeyError
// A KeyError with no Want entries means the host is simply unrecorded;
// with Want entries it means the recorded key does not match.
if errors.As(err, &ke) && len(ke.Want) == 0 && opts.AcceptHostKey != nil {
return acceptAndRecord(writeTo, hostname, key, opts)
}
return err
}, nil
}
// acceptAndRecord asks the user about an unrecorded host key and appends it to
// known_hosts when they accept.
func acceptAndRecord(file, hostname string, key ssh.PublicKey, opts Options) error {
ok, err := opts.AcceptHostKey(hostname, key)
if err != nil {
return err
}
if !ok {
return fmt.Errorf("host key for %s was not accepted", hostname)
}
if err := appendKnownHost(file, hostname, key); err != nil {
// Recording is best-effort: failing to write the file shouldn't sink a
// connection the user just explicitly approved.
opts.logf("could not record host key in %s: %v", file, err)
}
return nil
}
func appendKnownHost(file, hostname string, key ssh.PublicKey) error {
if err := os.MkdirAll(filepath.Dir(file), 0o700); err != nil {
return err
}
f, err := os.OpenFile(file, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return err
}
defer f.Close()
line := knownhosts.Line([]string{knownhosts.Normalize(hostname)}, key)
_, err = fmt.Fprintln(f, line)
return err
}
// FingerprintSHA256 renders a host key the way ssh presents it in its
// "authenticity of host ... can't be established" prompt, so a user can compare
// the two by eye.
func FingerprintSHA256(key ssh.PublicKey) string {
return ssh.FingerprintSHA256(key)
}
func expandTilde(path string) string {
if path == "~" || strings.HasPrefix(path, "~/") {
if home, err := os.UserHomeDir(); err == nil {
if path == "~" {
return home
}
return home + path[1:]
}
}
return path
}