6b52e83bdc
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>
476 lines
15 KiB
Go
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
|
|
}
|