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,264 @@
|
||||
// Package sshconf parses ~/.ssh/config (following Include directives) into a
|
||||
// flat list of connectable Host entries. It is strictly read-only: nothing in
|
||||
// this package ever writes to the ssh config tree.
|
||||
//
|
||||
// A small hand-written parser is used instead of a third-party library so that
|
||||
// two things the ssh_config format does not natively support are available:
|
||||
// - the "# tag: a, b" comment convention placed above a Host block, and
|
||||
// - full control over Include expansion order for stable, fast startup.
|
||||
package sshconf
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Host is a single connectable ssh alias with the preview fields dial surfaces.
|
||||
// It intentionally holds only what the picker needs; connecting is done by
|
||||
// exec'ing `ssh <Alias>` so the real ssh client re-resolves everything.
|
||||
type Host struct {
|
||||
Alias string
|
||||
HostName string
|
||||
User string
|
||||
Port string
|
||||
ProxyJump string
|
||||
Tags []string
|
||||
Source string // config file this entry was declared in
|
||||
}
|
||||
|
||||
// maxIncludeDepth guards against pathological or cyclic Include chains.
|
||||
const maxIncludeDepth = 16
|
||||
|
||||
// DefaultConfigPath returns ~/.ssh/config for the current user.
|
||||
func DefaultConfigPath() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, ".ssh", "config"), nil
|
||||
}
|
||||
|
||||
// Parse reads the config at path, follows Include directives, and returns the
|
||||
// concrete hosts in declaration order. A missing top-level config is not an
|
||||
// error: it yields an empty list so a fresh machine still launches cleanly.
|
||||
func Parse(path string) ([]Host, error) {
|
||||
sshDir := filepath.Dir(path)
|
||||
p := &parser{sshDir: sshDir, seen: map[string]bool{}}
|
||||
if err := p.parseFile(path, 0); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
out := make([]Host, len(p.hosts))
|
||||
for i, h := range p.hosts {
|
||||
out[i] = *h
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type parser struct {
|
||||
sshDir string
|
||||
seen map[string]bool // include cycle guard, by absolute path
|
||||
hosts []*Host // pointers so block updates stay valid across appends
|
||||
}
|
||||
|
||||
func (p *parser) parseFile(path string, depth int) error {
|
||||
if depth > maxIncludeDepth {
|
||||
return nil
|
||||
}
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
abs = path
|
||||
}
|
||||
if p.seen[abs] {
|
||||
return nil // already parsed; avoid include cycles
|
||||
}
|
||||
p.seen[abs] = true
|
||||
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
return p.parseReader(f, path, depth)
|
||||
}
|
||||
|
||||
func (p *parser) parseReader(r io.Reader, source string, depth int) error {
|
||||
sc := bufio.NewScanner(r)
|
||||
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
|
||||
var pendingTags []string // tags from a "# tag:" comment awaiting the next Host
|
||||
var block []*Host // all aliases in the current Host block
|
||||
|
||||
// applyKV sets a keyword/value pair on every host in the current block.
|
||||
applyKV := func(keyword, value string) {
|
||||
for _, h := range block {
|
||||
switch strings.ToLower(keyword) {
|
||||
case "hostname":
|
||||
h.HostName = value
|
||||
case "user":
|
||||
h.User = value
|
||||
case "port":
|
||||
h.Port = value
|
||||
case "proxyjump":
|
||||
h.ProxyJump = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for sc.Scan() {
|
||||
raw := sc.Text()
|
||||
line := strings.TrimSpace(raw)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(line, "#") {
|
||||
if tags, ok := parseTagComment(line); ok {
|
||||
pendingTags = append(pendingTags, tags...)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
keyword, value := splitKeyword(line)
|
||||
if keyword == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
switch strings.ToLower(keyword) {
|
||||
case "host":
|
||||
block = nil
|
||||
for _, alias := range strings.Fields(value) {
|
||||
if isPattern(alias) {
|
||||
continue // wildcard/negated patterns aren't connect targets
|
||||
}
|
||||
h := &Host{Alias: alias, Source: source, Tags: append([]string(nil), pendingTags...)}
|
||||
p.hosts = append(p.hosts, h)
|
||||
block = append(block, h)
|
||||
}
|
||||
pendingTags = nil
|
||||
|
||||
case "include":
|
||||
// Include is resolved relative to ~/.ssh and may contain globs.
|
||||
for _, pat := range strings.Fields(value) {
|
||||
for _, inc := range p.expandInclude(pat) {
|
||||
_ = p.parseFile(inc, depth+1)
|
||||
}
|
||||
}
|
||||
pendingTags = nil
|
||||
|
||||
case "match":
|
||||
// Match blocks are conditional; dial does not evaluate conditions.
|
||||
// End the current block so stray keywords don't attach to a host.
|
||||
block = nil
|
||||
pendingTags = nil
|
||||
|
||||
default:
|
||||
if len(block) > 0 {
|
||||
applyKV(keyword, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
return sc.Err()
|
||||
}
|
||||
|
||||
// expandInclude resolves an Include pattern to concrete file paths. Relative
|
||||
// patterns are anchored at ~/.ssh, matching OpenSSH behaviour.
|
||||
func (p *parser) expandInclude(pattern string) []string {
|
||||
pattern = expandTilde(pattern)
|
||||
if !filepath.IsAbs(pattern) {
|
||||
pattern = filepath.Join(p.sshDir, pattern)
|
||||
}
|
||||
matches, err := filepath.Glob(pattern)
|
||||
if err != nil || len(matches) == 0 {
|
||||
return nil
|
||||
}
|
||||
sort.Strings(matches) // deterministic order across machines
|
||||
// Filter out directories; only include regular files.
|
||||
var files []string
|
||||
for _, m := range matches {
|
||||
if fi, err := os.Stat(m); err == nil && !fi.IsDir() {
|
||||
files = append(files, m)
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
// parseTagComment recognises the "# tag: prod, riedel" convention and returns
|
||||
// the individual tags. It is lenient about spacing and the tag/tags spelling.
|
||||
func parseTagComment(line string) ([]string, bool) {
|
||||
body := strings.TrimSpace(strings.TrimPrefix(line, "#"))
|
||||
lower := strings.ToLower(body)
|
||||
var rest string
|
||||
switch {
|
||||
case strings.HasPrefix(lower, "tags:"):
|
||||
rest = body[len("tags:"):]
|
||||
case strings.HasPrefix(lower, "tag:"):
|
||||
rest = body[len("tag:"):]
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
var tags []string
|
||||
for _, t := range strings.Split(rest, ",") {
|
||||
if t = strings.TrimSpace(t); t != "" {
|
||||
tags = append(tags, t)
|
||||
}
|
||||
}
|
||||
return tags, len(tags) > 0
|
||||
}
|
||||
|
||||
// splitKeyword splits an ssh_config line into keyword and value. ssh_config
|
||||
// allows "Key Value" and "Key = Value"; both are handled here.
|
||||
func splitKeyword(line string) (string, string) {
|
||||
// Strip trailing inline comments only when clearly separated.
|
||||
if i := strings.Index(line, " #"); i >= 0 {
|
||||
// keep '#' inside values rare; safe to drop trailing comment
|
||||
line = strings.TrimSpace(line[:i])
|
||||
}
|
||||
if eq := strings.IndexByte(line, '='); eq >= 0 && !strings.ContainsAny(line[:eq], " \t") {
|
||||
return strings.TrimSpace(line[:eq]), unquote(strings.TrimSpace(line[eq+1:]))
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) == 0 {
|
||||
return "", ""
|
||||
}
|
||||
if len(fields) == 1 {
|
||||
return fields[0], ""
|
||||
}
|
||||
// Support "Key = Value" where '=' is its own token.
|
||||
if fields[1] == "=" {
|
||||
return fields[0], unquote(strings.TrimSpace(strings.Join(fields[2:], " ")))
|
||||
}
|
||||
return fields[0], unquote(strings.TrimSpace(strings.Join(fields[1:], " ")))
|
||||
}
|
||||
|
||||
func unquote(s string) string {
|
||||
if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' {
|
||||
return s[1 : len(s)-1]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// isPattern reports whether an alias is a wildcard/negation pattern rather than
|
||||
// a concrete connectable host.
|
||||
func isPattern(alias string) bool {
|
||||
return strings.ContainsAny(alias, "*?!")
|
||||
}
|
||||
|
||||
func expandTilde(path string) string {
|
||||
if path == "~" || strings.HasPrefix(path, "~/") {
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
if path == "~" {
|
||||
return home
|
||||
}
|
||||
return filepath.Join(home, path[2:])
|
||||
}
|
||||
}
|
||||
return path
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package sshconf
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseWithIncludeAndTags(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
incDir := filepath.Join(dir, "conf.d")
|
||||
if err := os.MkdirAll(incDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
main := `# global defaults
|
||||
Host *
|
||||
ServerAliveInterval 60
|
||||
|
||||
# tag: prod, riedel
|
||||
Host web1 web2
|
||||
HostName 10.0.0.5
|
||||
User deploy
|
||||
Port 2222
|
||||
ProxyJump bastion
|
||||
|
||||
Include conf.d/*.conf
|
||||
|
||||
Host bastion
|
||||
HostName bastion.example.com
|
||||
User admin
|
||||
`
|
||||
inc := `# tag: staging
|
||||
Host stage
|
||||
HostName stage.example.com
|
||||
User dev = ignored
|
||||
Host = eqform
|
||||
HostName eq.example.com
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(dir, "config"), []byte(main), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(incDir, "a.conf"), []byte(inc), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
hosts, err := Parse(filepath.Join(dir, "config"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
byAlias := map[string]Host{}
|
||||
for _, h := range hosts {
|
||||
byAlias[h.Alias] = h
|
||||
}
|
||||
|
||||
// Wildcard "Host *" must not appear as a connectable target.
|
||||
if _, ok := byAlias["*"]; ok {
|
||||
t.Error("wildcard host * should be skipped")
|
||||
}
|
||||
|
||||
// Multiple aliases in one block share settings and tags.
|
||||
for _, a := range []string{"web1", "web2"} {
|
||||
h, ok := byAlias[a]
|
||||
if !ok {
|
||||
t.Fatalf("missing host %s", a)
|
||||
}
|
||||
if h.HostName != "10.0.0.5" || h.User != "deploy" || h.Port != "2222" || h.ProxyJump != "bastion" {
|
||||
t.Errorf("%s wrong fields: %+v", a, h)
|
||||
}
|
||||
if len(h.Tags) != 2 || h.Tags[0] != "prod" || h.Tags[1] != "riedel" {
|
||||
t.Errorf("%s wrong tags: %v", a, h.Tags)
|
||||
}
|
||||
}
|
||||
|
||||
// Included file's host and its tag.
|
||||
stage, ok := byAlias["stage"]
|
||||
if !ok {
|
||||
t.Fatal("included host 'stage' missing")
|
||||
}
|
||||
if len(stage.Tags) != 1 || stage.Tags[0] != "staging" {
|
||||
t.Errorf("stage tags wrong: %v", stage.Tags)
|
||||
}
|
||||
|
||||
// "Host = eqform" equals-form should parse the alias.
|
||||
if _, ok := byAlias["eqform"]; !ok {
|
||||
t.Error("equals-form Host 'eqform' missing")
|
||||
}
|
||||
|
||||
// Ordering: web1 before included stage before bastion.
|
||||
order := []string{}
|
||||
for _, h := range hosts {
|
||||
order = append(order, h.Alias)
|
||||
}
|
||||
t.Logf("order: %v", order)
|
||||
}
|
||||
|
||||
func TestParseMissingConfig(t *testing.T) {
|
||||
hosts, err := Parse(filepath.Join(t.TempDir(), "does-not-exist"))
|
||||
if err != nil {
|
||||
t.Fatalf("missing config should not error: %v", err)
|
||||
}
|
||||
if hosts != nil {
|
||||
t.Errorf("expected nil hosts, got %v", hosts)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user