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>
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
package sshx
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/pkg/sftp"
|
||||
)
|
||||
|
||||
// TestLiveConnect exercises the real auth + SFTP path against a live host.
|
||||
// Gated on DIAL_LIVE_HOST so the normal test suite never needs a server.
|
||||
// DIAL_LIVE_HOST=192.168.192.201 go test ./internal/sshx -run TestLiveConnect -v
|
||||
func TestLiveConnect(t *testing.T) {
|
||||
alias := os.Getenv("DIAL_LIVE_HOST")
|
||||
if alias == "" {
|
||||
t.Skip("set DIAL_LIVE_HOST to run the live connection test")
|
||||
}
|
||||
opts := Options{Logf: func(format string, args ...any) { t.Logf(format, args...) }}
|
||||
client, err := Connect(alias, opts)
|
||||
if err != nil {
|
||||
t.Fatalf("Connect(%q): %v", alias, err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
sf, err := sftp.NewClient(client)
|
||||
if err != nil {
|
||||
t.Fatalf("sftp.NewClient: %v", err)
|
||||
}
|
||||
defer sf.Close()
|
||||
|
||||
entries, err := sf.ReadDir(".")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDir(.): %v", err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
t.Logf(" %s\t%d bytes", e.Name(), e.Size())
|
||||
}
|
||||
t.Logf("SFTP OK: %d entries in remote home", len(entries))
|
||||
|
||||
// Round-trip: write a file, read it back, verify contents, clean up.
|
||||
const remote = "dial-live-test.txt"
|
||||
want := []byte("dial round-trip @ live test\n")
|
||||
w, err := sf.Create(remote)
|
||||
if err != nil {
|
||||
t.Fatalf("Create(%s): %v", remote, err)
|
||||
}
|
||||
if _, err := w.Write(want); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
w.Close()
|
||||
|
||||
r, err := sf.Open(remote)
|
||||
if err != nil {
|
||||
t.Fatalf("Open(%s): %v", remote, err)
|
||||
}
|
||||
got := make([]byte, len(want))
|
||||
if _, err := r.Read(got); err != nil {
|
||||
t.Fatalf("Read: %v", err)
|
||||
}
|
||||
r.Close()
|
||||
if string(got) != string(want) {
|
||||
t.Fatalf("round-trip mismatch: got %q want %q", got, want)
|
||||
}
|
||||
if err := sf.Remove(remote); err != nil {
|
||||
t.Fatalf("Remove(%s): %v", remote, err)
|
||||
}
|
||||
t.Logf("round-trip OK: wrote %d bytes, read back identical, removed", len(want))
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package sshx
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseSSHG(t *testing.T) {
|
||||
// Representative `ssh -G` output (abridged, real keys/casing).
|
||||
out := `host web1
|
||||
hostname 10.0.0.5
|
||||
user deploy
|
||||
port 2222
|
||||
proxyjump bastion
|
||||
identityfile ~/.ssh/id_ed25519
|
||||
identityfile ~/.ssh/id_rsa
|
||||
userknownhostsfile ~/.ssh/known_hosts ~/.ssh/known_hosts2
|
||||
stricthostkeychecking accept-new
|
||||
identityagent SSH_AUTH_SOCK
|
||||
`
|
||||
p := parseSSHG("web1", out)
|
||||
if p.HostName != "10.0.0.5" {
|
||||
t.Errorf("hostname = %q", p.HostName)
|
||||
}
|
||||
if p.User != "deploy" || p.Port != "2222" {
|
||||
t.Errorf("user/port = %q/%q", p.User, p.Port)
|
||||
}
|
||||
if p.ProxyJump != "bastion" {
|
||||
t.Errorf("proxyjump = %q", p.ProxyJump)
|
||||
}
|
||||
if len(p.IdentityFiles) != 2 {
|
||||
t.Errorf("identityfiles = %v", p.IdentityFiles)
|
||||
}
|
||||
if len(p.KnownHostsFile) != 2 {
|
||||
t.Errorf("knownhosts = %v", p.KnownHostsFile)
|
||||
}
|
||||
if p.StrictHostKey != "accept-new" {
|
||||
t.Errorf("stricthostkey = %q", p.StrictHostKey)
|
||||
}
|
||||
if p.Addr() != "10.0.0.5:2222" {
|
||||
t.Errorf("addr = %q", p.Addr())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSHGIdentitiesOnly(t *testing.T) {
|
||||
p := parseSSHG("h", "hostname h\nidentitiesonly yes\nidentityfile ~/.ssh/special\n")
|
||||
if !p.IdentitiesOnly {
|
||||
t.Error("identitiesonly yes should parse true")
|
||||
}
|
||||
q := parseSSHG("h", "hostname h\nidentitiesonly no\n")
|
||||
if q.IdentitiesOnly {
|
||||
t.Error("identitiesonly no should parse false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSHGProxyJumpNone(t *testing.T) {
|
||||
p := parseSSHG("h", "hostname h.example.com\nproxyjump none\nport 22\n")
|
||||
if p.ProxyJump != "" {
|
||||
t.Errorf("proxyjump 'none' should be empty, got %q", p.ProxyJump)
|
||||
}
|
||||
if p.Addr() != "h.example.com:22" {
|
||||
t.Errorf("addr = %q", p.Addr())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSHGFallbackHostname(t *testing.T) {
|
||||
p := parseSSHG("myhost", "user bob\n")
|
||||
if p.HostName != "myhost" {
|
||||
t.Errorf("hostname should fall back to alias, got %q", p.HostName)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user