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,2 @@
|
|||||||
|
/bin/
|
||||||
|
/dist/
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
VERSION ?= 0.1.0
|
||||||
|
# No -X main.version here: cmd/haul has no version var yet, and a -X against a
|
||||||
|
# symbol that doesn't exist is silently a no-op rather than an error.
|
||||||
|
LDFLAGS := -s -w
|
||||||
|
|
||||||
|
# Go is installed at ~/.local/go on this box and deliberately kept off PATH, so
|
||||||
|
# prefer that toolchain and fall back to whatever `go` is in PATH elsewhere.
|
||||||
|
GO ?= $(shell test -x $(HOME)/.local/go/bin/go && echo $(HOME)/.local/go/bin/go || echo go)
|
||||||
|
|
||||||
|
.PHONY: build install test vet check run run-xvfb dist clean
|
||||||
|
|
||||||
|
build: ## Build a native binary into ./bin/haul
|
||||||
|
$(GO) build -trimpath -ldflags "$(LDFLAGS)" -o bin/haul ./cmd/haul
|
||||||
|
|
||||||
|
install: ## Install haul into $GOBIN / $GOPATH/bin
|
||||||
|
$(GO) install -trimpath -ldflags "$(LDFLAGS)" ./cmd/haul
|
||||||
|
|
||||||
|
test: ## Run tests
|
||||||
|
$(GO) test ./...
|
||||||
|
|
||||||
|
vet: ## Run go vet
|
||||||
|
$(GO) vet ./...
|
||||||
|
|
||||||
|
# GOOS=js typechecks every package, Fyne API usage included, without needing the
|
||||||
|
# cgo/X11 toolchain. It proves nothing about layout or runtime behaviour.
|
||||||
|
check: ## Typecheck the whole tree without cgo
|
||||||
|
GOOS=js GOARCH=wasm $(GO) build ./...
|
||||||
|
|
||||||
|
run: build ## Build and run (needs a DISPLAY)
|
||||||
|
./bin/haul
|
||||||
|
|
||||||
|
# This box is headless. Xvfb gives haul a display to render into; software GL
|
||||||
|
# because there's no GPU.
|
||||||
|
#
|
||||||
|
# Cleanup is a trap on a PID held in a shell variable, never a pidfile and never
|
||||||
|
# pkill. A pidfile survives Ctrl-C and goes stale, and PIDs get recycled, so a
|
||||||
|
# later `kill $(cat .xvfb.pid)` can hit an unrelated process. `pkill -f Xvfb` is
|
||||||
|
# worse still: the pattern also matches make's own command line, and this box
|
||||||
|
# runs inside tmux, where a stray match can take out the session.
|
||||||
|
run-xvfb: build ## Build and run against a virtual X display
|
||||||
|
@if [ -e /tmp/.X99-lock ]; then \
|
||||||
|
echo "display :99 is already in use — close that first"; exit 1; \
|
||||||
|
fi; \
|
||||||
|
Xvfb :99 -screen 0 1500x900x24 >/dev/null 2>&1 & \
|
||||||
|
xvfb_pid=$$!; \
|
||||||
|
trap 'kill $$xvfb_pid 2>/dev/null || true' INT TERM EXIT; \
|
||||||
|
sleep 1; \
|
||||||
|
DISPLAY=:99 LIBGL_ALWAYS_SOFTWARE=1 ./bin/haul || true
|
||||||
|
|
||||||
|
# haul is cgo (Fyne needs OpenGL + X11), so `GOOS=windows go build` does NOT
|
||||||
|
# work the way it does for dial. Cross-targets need a matching C toolchain:
|
||||||
|
# linux/arm64 -> apt install gcc-aarch64-linux-gnu
|
||||||
|
# windows/amd64-> apt install gcc-mingw-w64
|
||||||
|
# darwin/* -> a Mac, or osxcross
|
||||||
|
# Docker is present, so `fyne-cross` is the practical route for the others:
|
||||||
|
# go install github.com/fyne-io/fyne-cross@latest
|
||||||
|
# fyne-cross windows -arch=amd64
|
||||||
|
dist: ## Build the release binary for this host into ./dist
|
||||||
|
@mkdir -p dist
|
||||||
|
$(GO) build -trimpath -ldflags "$(LDFLAGS)" -o dist/haul-linux-amd64 ./cmd/haul
|
||||||
|
@echo "built dist/haul-linux-amd64 (see the Makefile for cross-compiling)"
|
||||||
|
|
||||||
|
clean: ## Remove build artifacts
|
||||||
|
rm -rf bin dist
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// Command haul is a desktop SFTP client: a two-pane local/remote file browser
|
||||||
|
// over SSH, with ~/.ssh/config as its host list and quick connect for anything
|
||||||
|
// that isn't in it.
|
||||||
|
//
|
||||||
|
// haul was split out of `dial` (the SSH host picker) so file transfer lives in
|
||||||
|
// its own tool. The two share the same ~/.ssh/config as their base.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"gitea.apointless.space/bsncubed/haul/internal/ui"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := ui.Run(); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "haul:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
module gitea.apointless.space/bsncubed/haul
|
||||||
|
|
||||||
|
go 1.24
|
||||||
|
|
||||||
|
require (
|
||||||
|
fyne.io/fyne/v2 v2.6.1
|
||||||
|
github.com/pkg/sftp v1.13.9
|
||||||
|
golang.org/x/crypto v0.36.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
fyne.io/systray v1.11.0 // indirect
|
||||||
|
github.com/BurntSushi/toml v1.4.0 // indirect
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/fredbi/uri v1.1.0 // indirect
|
||||||
|
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||||
|
github.com/fyne-io/gl-js v0.1.0 // indirect
|
||||||
|
github.com/fyne-io/glfw-js v0.2.0 // indirect
|
||||||
|
github.com/fyne-io/image v0.1.1 // indirect
|
||||||
|
github.com/fyne-io/oksvg v0.1.0 // indirect
|
||||||
|
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect
|
||||||
|
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a // indirect
|
||||||
|
github.com/go-text/render v0.2.0 // indirect
|
||||||
|
github.com/go-text/typesetting v0.2.1 // indirect
|
||||||
|
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||||
|
github.com/hack-pad/go-indexeddb v0.3.2 // indirect
|
||||||
|
github.com/hack-pad/safejs v0.1.0 // indirect
|
||||||
|
github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08 // indirect
|
||||||
|
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect
|
||||||
|
github.com/kr/fs v0.1.0 // indirect
|
||||||
|
github.com/kr/text v0.2.0 // indirect
|
||||||
|
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
|
||||||
|
github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
github.com/rymdport/portal v0.4.1 // indirect
|
||||||
|
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect
|
||||||
|
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
|
||||||
|
github.com/stretchr/testify v1.10.0 // indirect
|
||||||
|
github.com/yuin/goldmark v1.7.8 // indirect
|
||||||
|
golang.org/x/image v0.24.0 // indirect
|
||||||
|
golang.org/x/net v0.35.0 // indirect
|
||||||
|
golang.org/x/sys v0.31.0 // indirect
|
||||||
|
golang.org/x/text v0.23.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
fyne.io/fyne/v2 v2.6.1 h1:kjPJD4/rBS9m2nHJp+npPSuaK79yj6ObMTuzR6VQ1Is=
|
||||||
|
fyne.io/fyne/v2 v2.6.1/go.mod h1:YZt7SksjvrSNJCwbWFV32WON3mE1Sr7L41D29qMZ/lU=
|
||||||
|
fyne.io/systray v1.11.0 h1:D9HISlxSkx+jHSniMBR6fCFOUjk1x/OOOJLa9lJYAKg=
|
||||||
|
fyne.io/systray v1.11.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
|
||||||
|
github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0=
|
||||||
|
github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||||
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g=
|
||||||
|
github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw=
|
||||||
|
github.com/fredbi/uri v1.1.0 h1:OqLpTXtyRg9ABReqvDGdJPqZUxs8cyBDOMXBbskCaB8=
|
||||||
|
github.com/fredbi/uri v1.1.0/go.mod h1:aYTUoAXBOq7BLfVJ8GnKmfcuURosB1xyHDIfWeC/iW4=
|
||||||
|
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||||
|
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||||
|
github.com/fyne-io/gl-js v0.1.0 h1:8luJzNs0ntEAJo+8x8kfUOXujUlP8gB3QMOxO2mUdpM=
|
||||||
|
github.com/fyne-io/gl-js v0.1.0/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI=
|
||||||
|
github.com/fyne-io/glfw-js v0.2.0 h1:8GUZtN2aCoTPNqgRDxK5+kn9OURINhBEBc7M4O1KrmM=
|
||||||
|
github.com/fyne-io/glfw-js v0.2.0/go.mod h1:Ri6te7rdZtBgBpxLW19uBpp3Dl6K9K/bRaYdJ22G8Jk=
|
||||||
|
github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA=
|
||||||
|
github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM=
|
||||||
|
github.com/fyne-io/oksvg v0.1.0 h1:7EUKk3HV3Y2E+qypp3nWqMXD7mum0hCw2KEGhI1fnBw=
|
||||||
|
github.com/fyne-io/oksvg v0.1.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI=
|
||||||
|
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA=
|
||||||
|
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw=
|
||||||
|
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a h1:vxnBhFDDT+xzxf1jTJKMKZw3H0swfWk9RpWbBbDK5+0=
|
||||||
|
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||||
|
github.com/go-text/render v0.2.0 h1:LBYoTmp5jYiJ4NPqDc2pz17MLmA3wHw1dZSVGcOdeAc=
|
||||||
|
github.com/go-text/render v0.2.0/go.mod h1:CkiqfukRGKJA5vZZISkjSYrcdtgKQWRa2HIzvwNN5SU=
|
||||||
|
github.com/go-text/typesetting v0.2.1 h1:x0jMOGyO3d1qFAPI0j4GSsh7M0Q3Ypjzr4+CEVg82V8=
|
||||||
|
github.com/go-text/typesetting v0.2.1/go.mod h1:mTOxEwasOFpAMBjEQDhdWRckoLLeI/+qrQeBCTGEt6M=
|
||||||
|
github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066 h1:qCuYC+94v2xrb1PoS4NIDe7DGYtLnU2wWiQe9a1B1c0=
|
||||||
|
github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o=
|
||||||
|
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||||
|
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y=
|
||||||
|
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg=
|
||||||
|
github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A=
|
||||||
|
github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0=
|
||||||
|
github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8=
|
||||||
|
github.com/hack-pad/safejs v0.1.0/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio=
|
||||||
|
github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08 h1:wMeVzrPO3mfHIWLZtDcSaGAe2I4PW9B/P5nMkRSwCAc=
|
||||||
|
github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o=
|
||||||
|
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M=
|
||||||
|
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw=
|
||||||
|
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
|
||||||
|
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
|
||||||
|
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
|
||||||
|
github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk=
|
||||||
|
github.com/nicksnyder/go-i18n/v2 v2.5.1/go.mod h1:DrhgsSDZxoAfvVrBVLXoxZn/pN5TXqaDbq7ju94viiQ=
|
||||||
|
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
|
||||||
|
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||||
|
github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA=
|
||||||
|
github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo=
|
||||||
|
github.com/pkg/sftp v1.13.9 h1:4NGkvGudBL7GteO3m6qnaQ4pC0Kvf0onSVc9gR3EWBw=
|
||||||
|
github.com/pkg/sftp v1.13.9/go.mod h1:OBN7bVXdstkFFN/gdnHPUb5TE8eb8G1Rp9wCItqjkkA=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rymdport/portal v0.4.1 h1:2dnZhjf5uEaeDjeF/yBIeeRo6pNI2QAKm7kq1w/kbnA=
|
||||||
|
github.com/rymdport/portal v0.4.1/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4=
|
||||||
|
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
|
||||||
|
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
|
||||||
|
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
|
||||||
|
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
||||||
|
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||||
|
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||||
|
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||||
|
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||||
|
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
||||||
|
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
||||||
|
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
|
||||||
|
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
|
||||||
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
|
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
|
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
|
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
|
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||||
|
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||||
|
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||||
|
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||||
|
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||||
|
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||||
|
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||||
|
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
|
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
|
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||||
|
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||||
|
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||||
|
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||||
|
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||||
|
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
|
||||||
|
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
|
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
|
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||||
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
|
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
|
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||||
|
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||||
|
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
|
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||||
|
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"fyne.io/fyne/v2"
|
||||||
|
"fyne.io/fyne/v2/container"
|
||||||
|
"fyne.io/fyne/v2/dialog"
|
||||||
|
"fyne.io/fyne/v2/theme"
|
||||||
|
"fyne.io/fyne/v2/widget"
|
||||||
|
"github.com/pkg/sftp"
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
|
||||||
|
"gitea.apointless.space/bsncubed/haul/internal/sshconf"
|
||||||
|
"gitea.apointless.space/bsncubed/haul/internal/sshx"
|
||||||
|
)
|
||||||
|
|
||||||
|
// connectWindow is haul's front door: the hosts already in ~/.ssh/config on one
|
||||||
|
// tab, an ad-hoc target on the other. It stays alive behind each session so
|
||||||
|
// closing a session returns here rather than quitting.
|
||||||
|
type connectWindow struct {
|
||||||
|
app fyne.App
|
||||||
|
win fyne.Window
|
||||||
|
|
||||||
|
cfgPath string
|
||||||
|
hosts []sshconf.Host // everything parsed from the config
|
||||||
|
shown []sshconf.Host // what the filter currently leaves visible
|
||||||
|
list *widget.List
|
||||||
|
selected int
|
||||||
|
|
||||||
|
qHost *widget.Entry
|
||||||
|
qPort *widget.Entry
|
||||||
|
qUser *widget.Entry
|
||||||
|
qPass *widget.Entry
|
||||||
|
qKey *widget.Entry
|
||||||
|
|
||||||
|
tabs *container.AppTabs
|
||||||
|
connectBtn *widget.Button
|
||||||
|
status *widget.Label
|
||||||
|
loadErr string // config problem found before the status label existed
|
||||||
|
}
|
||||||
|
|
||||||
|
func newConnectWindow(app fyne.App) *connectWindow {
|
||||||
|
c := &connectWindow{
|
||||||
|
app: app,
|
||||||
|
win: app.NewWindow("haul"),
|
||||||
|
selected: -1,
|
||||||
|
}
|
||||||
|
|
||||||
|
c.loadHosts()
|
||||||
|
savedTab := container.NewTabItemWithIcon("Saved hosts", theme.ComputerIcon(), c.buildSavedTab())
|
||||||
|
quickTab := container.NewTabItemWithIcon("Quick connect", theme.SearchIcon(), c.buildQuickTab())
|
||||||
|
c.tabs = container.NewAppTabs(savedTab, quickTab)
|
||||||
|
|
||||||
|
c.status = widget.NewLabel(c.loadErr)
|
||||||
|
c.status.Wrapping = fyne.TextWrapWord
|
||||||
|
c.connectBtn = widget.NewButtonWithIcon("Connect", theme.ConfirmIcon(), c.connect)
|
||||||
|
c.connectBtn.Importance = widget.HighImportance
|
||||||
|
|
||||||
|
bottom := container.NewBorder(widget.NewSeparator(), nil, nil, c.connectBtn, c.status)
|
||||||
|
c.win.SetContent(container.NewBorder(nil, bottom, nil, nil, c.tabs))
|
||||||
|
c.win.Resize(fyne.NewSize(620, 520))
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- saved hosts ---
|
||||||
|
|
||||||
|
func (c *connectWindow) loadHosts() {
|
||||||
|
path, err := sshconf.DefaultConfigPath()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.cfgPath = path
|
||||||
|
hosts, err := sshconf.Parse(path)
|
||||||
|
if err != nil {
|
||||||
|
// A malformed config shouldn't stop haul launching — quick connect still
|
||||||
|
// works — so the problem is held here and shown once the window is built.
|
||||||
|
c.loadErr = "Could not read " + path + ": " + err.Error()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.hosts = hosts
|
||||||
|
c.shown = hosts
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *connectWindow) buildSavedTab() fyne.CanvasObject {
|
||||||
|
search := widget.NewEntry()
|
||||||
|
search.SetPlaceHolder("Filter hosts…")
|
||||||
|
search.OnChanged = c.filter
|
||||||
|
|
||||||
|
c.list = widget.NewList(
|
||||||
|
func() int { return len(c.shown) },
|
||||||
|
func() fyne.CanvasObject {
|
||||||
|
return container.NewVBox(
|
||||||
|
widget.NewLabelWithStyle("", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||||
|
widget.NewLabel(""),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
func(id widget.ListItemID, o fyne.CanvasObject) {
|
||||||
|
box, ok := o.(*fyne.Container)
|
||||||
|
if !ok || id < 0 || id >= len(c.shown) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h := c.shown[id]
|
||||||
|
alias := box.Objects[0].(*widget.Label)
|
||||||
|
detail := box.Objects[1].(*widget.Label)
|
||||||
|
alias.SetText(h.Alias)
|
||||||
|
detail.SetText(describeHost(h))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
c.list.OnSelected = func(id widget.ListItemID) { c.selected = id }
|
||||||
|
|
||||||
|
// The "no hosts" note is only added when it has something to say — an empty
|
||||||
|
// label still occupies a full row, which reads as a rendering glitch.
|
||||||
|
top := fyne.CanvasObject(search)
|
||||||
|
if len(c.hosts) == 0 {
|
||||||
|
note := widget.NewLabel("No hosts found in " + c.cfgPath + " — use Quick connect.")
|
||||||
|
note.Wrapping = fyne.TextWrapWord
|
||||||
|
top = container.NewVBox(search, note)
|
||||||
|
}
|
||||||
|
|
||||||
|
return container.NewBorder(top, nil, nil, nil, c.list)
|
||||||
|
}
|
||||||
|
|
||||||
|
// describeHost is the one-line summary under each alias in the list.
|
||||||
|
func describeHost(h sshconf.Host) string {
|
||||||
|
target := h.HostName
|
||||||
|
if target == "" {
|
||||||
|
target = h.Alias
|
||||||
|
}
|
||||||
|
if h.User != "" {
|
||||||
|
target = h.User + "@" + target
|
||||||
|
}
|
||||||
|
if h.Port != "" && h.Port != "22" {
|
||||||
|
target += ":" + h.Port
|
||||||
|
}
|
||||||
|
if h.ProxyJump != "" {
|
||||||
|
target += " (via " + h.ProxyJump + ")"
|
||||||
|
}
|
||||||
|
if len(h.Tags) > 0 {
|
||||||
|
target += " [" + strings.Join(h.Tags, ", ") + "]"
|
||||||
|
}
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *connectWindow) filter(q string) {
|
||||||
|
q = strings.ToLower(strings.TrimSpace(q))
|
||||||
|
if q == "" {
|
||||||
|
c.shown = c.hosts
|
||||||
|
} else {
|
||||||
|
c.shown = nil
|
||||||
|
for _, h := range c.hosts {
|
||||||
|
if strings.Contains(strings.ToLower(h.Alias), q) ||
|
||||||
|
strings.Contains(strings.ToLower(h.HostName), q) ||
|
||||||
|
strings.Contains(strings.ToLower(strings.Join(h.Tags, " ")), q) {
|
||||||
|
c.shown = append(c.shown, h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.selected = -1
|
||||||
|
c.list.UnselectAll()
|
||||||
|
c.list.Refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- quick connect ---
|
||||||
|
|
||||||
|
func (c *connectWindow) buildQuickTab() fyne.CanvasObject {
|
||||||
|
c.qHost = widget.NewEntry()
|
||||||
|
c.qHost.SetPlaceHolder("hostname or IP")
|
||||||
|
c.qPort = widget.NewEntry()
|
||||||
|
c.qPort.SetText("22")
|
||||||
|
c.qUser = widget.NewEntry()
|
||||||
|
c.qUser.SetText(currentUser())
|
||||||
|
c.qPass = widget.NewPasswordEntry()
|
||||||
|
c.qPass.SetPlaceHolder("leave blank to use ssh-agent / key")
|
||||||
|
c.qKey = widget.NewEntry()
|
||||||
|
c.qKey.SetPlaceHolder("optional private key file")
|
||||||
|
|
||||||
|
browse := widget.NewButtonWithIcon("", theme.FolderOpenIcon(), func() {
|
||||||
|
dialog.ShowFileOpen(func(r fyne.URIReadCloser, err error) {
|
||||||
|
if err != nil || r == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
c.qKey.SetText(r.URI().Path())
|
||||||
|
}, c.win)
|
||||||
|
})
|
||||||
|
|
||||||
|
form := widget.NewForm(
|
||||||
|
widget.NewFormItem("Host", c.qHost),
|
||||||
|
widget.NewFormItem("Port", c.qPort),
|
||||||
|
widget.NewFormItem("User", c.qUser),
|
||||||
|
widget.NewFormItem("Password", c.qPass),
|
||||||
|
widget.NewFormItem("Key file", container.NewBorder(nil, nil, nil, browse, c.qKey)),
|
||||||
|
)
|
||||||
|
// Enter anywhere in the form connects.
|
||||||
|
c.qHost.OnSubmitted = func(string) { c.connect() }
|
||||||
|
c.qPass.OnSubmitted = func(string) { c.connect() }
|
||||||
|
|
||||||
|
note := widget.NewLabel("Quick connect doesn't touch ~/.ssh/config. An unrecognised host key is " +
|
||||||
|
"shown for confirmation before it's recorded in known_hosts.")
|
||||||
|
note.Wrapping = fyne.TextWrapWord
|
||||||
|
|
||||||
|
return container.NewVBox(form, widget.NewSeparator(), note)
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentUser() string {
|
||||||
|
for _, k := range []string{"USER", "USERNAME", "LOGNAME"} {
|
||||||
|
if v := os.Getenv(k); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- connecting ---
|
||||||
|
|
||||||
|
func (c *connectWindow) connect() {
|
||||||
|
if c.tabs.SelectedIndex() == 1 {
|
||||||
|
c.connectQuick()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.connectSaved()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *connectWindow) connectSaved() {
|
||||||
|
if c.selected < 0 || c.selected >= len(c.shown) {
|
||||||
|
c.setStatus("Select a host first.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
alias := c.shown[c.selected].Alias
|
||||||
|
c.start(alias, "", func(opts sshx.Options) (*ssh.Client, error) {
|
||||||
|
return sshx.Connect(alias, opts)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *connectWindow) connectQuick() {
|
||||||
|
host := strings.TrimSpace(c.qHost.Text)
|
||||||
|
if host == "" {
|
||||||
|
c.setStatus("Enter a hostname.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// "user@host:port" typed into the host box is understood, since that's how
|
||||||
|
// people paste a target.
|
||||||
|
user := strings.TrimSpace(c.qUser.Text)
|
||||||
|
port := strings.TrimSpace(c.qPort.Text)
|
||||||
|
if u, rest, ok := strings.Cut(host, "@"); ok {
|
||||||
|
user, host = u, rest
|
||||||
|
}
|
||||||
|
if h, p, ok := strings.Cut(host, ":"); ok {
|
||||||
|
host, port = h, p
|
||||||
|
}
|
||||||
|
if port == "" {
|
||||||
|
port = "22"
|
||||||
|
}
|
||||||
|
|
||||||
|
p := sshx.Params{
|
||||||
|
Alias: host,
|
||||||
|
HostName: host,
|
||||||
|
User: user,
|
||||||
|
Port: port,
|
||||||
|
}
|
||||||
|
if key := strings.TrimSpace(c.qKey.Text); key != "" {
|
||||||
|
p.IdentityFiles = []string{key}
|
||||||
|
p.IdentitiesOnly = true
|
||||||
|
}
|
||||||
|
label := host
|
||||||
|
if user != "" {
|
||||||
|
label = user + "@" + host
|
||||||
|
}
|
||||||
|
c.start(label, c.qPass.Text, func(opts sshx.Options) (*ssh.Client, error) {
|
||||||
|
return sshx.ConnectParams(p, opts)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// start runs a connection attempt off the UI thread and opens a session window
|
||||||
|
// on success. dial is whichever sshx entry point the chosen tab implies.
|
||||||
|
func (c *connectWindow) start(name, password string, dial func(sshx.Options) (*ssh.Client, error)) {
|
||||||
|
c.connectBtn.Disable()
|
||||||
|
c.setStatus("Connecting to " + name + "…")
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
var diagMu sync.Mutex
|
||||||
|
var diag []string
|
||||||
|
|
||||||
|
opts := c.sshOptions(password, func(format string, args ...any) {
|
||||||
|
diagMu.Lock()
|
||||||
|
defer diagMu.Unlock()
|
||||||
|
diag = append(diag, fmt.Sprintf(format, args...))
|
||||||
|
})
|
||||||
|
|
||||||
|
client, err := dial(opts)
|
||||||
|
if err != nil {
|
||||||
|
diagMu.Lock()
|
||||||
|
detail := strings.Join(diag, "\n · ")
|
||||||
|
diagMu.Unlock()
|
||||||
|
fyne.Do(func() {
|
||||||
|
c.connectBtn.Enable()
|
||||||
|
c.setStatus("")
|
||||||
|
c.showConnectError(err, detail)
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sf, err := sftp.NewClient(client)
|
||||||
|
if err != nil {
|
||||||
|
client.Close()
|
||||||
|
fyne.Do(func() {
|
||||||
|
c.connectBtn.Enable()
|
||||||
|
c.setStatus("")
|
||||||
|
dialog.ShowError(fmt.Errorf("opening SFTP subsystem: %w", err), c.win)
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fyne.Do(func() {
|
||||||
|
c.connectBtn.Enable()
|
||||||
|
c.setStatus("")
|
||||||
|
c.win.Hide()
|
||||||
|
newSession(c.app, name, client, sf, func() { c.win.Show() })
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// sshOptions wires sshx's blocking callbacks to dialogs on the UI thread. Each
|
||||||
|
// callback is invoked from the connecting goroutine and waits for an answer.
|
||||||
|
func (c *connectWindow) sshOptions(initialPassword string, logf func(string, ...any)) sshx.Options {
|
||||||
|
var mu sync.Mutex
|
||||||
|
cached := initialPassword
|
||||||
|
|
||||||
|
return sshx.Options{
|
||||||
|
Passphrase: func(keyfile string) ([]byte, error) {
|
||||||
|
reply := make(chan []byte, 1)
|
||||||
|
fyne.Do(func() {
|
||||||
|
askSecret(c.win, "Key passphrase", "Enter the passphrase for "+keyfile, reply)
|
||||||
|
})
|
||||||
|
return <-reply, nil
|
||||||
|
},
|
||||||
|
Password: func(user, host string) ([]byte, error) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
// A password typed on the quick-connect form is reused for the
|
||||||
|
// keyboard-interactive round rather than prompting twice.
|
||||||
|
if cached != "" {
|
||||||
|
return []byte(cached), nil
|
||||||
|
}
|
||||||
|
reply := make(chan []byte, 1)
|
||||||
|
target := host
|
||||||
|
if user != "" {
|
||||||
|
target = user + "@" + host
|
||||||
|
}
|
||||||
|
fyne.Do(func() {
|
||||||
|
askSecret(c.win, "Password", "Password for "+target, reply)
|
||||||
|
})
|
||||||
|
b := <-reply
|
||||||
|
cached = string(b)
|
||||||
|
return b, nil
|
||||||
|
},
|
||||||
|
AcceptHostKey: func(host string, key ssh.PublicKey) (bool, error) {
|
||||||
|
reply := make(chan bool, 1)
|
||||||
|
fyne.Do(func() { askHostKey(c.win, host, key, reply) })
|
||||||
|
return <-reply, nil
|
||||||
|
},
|
||||||
|
Logf: logf,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// showConnectError presents the failure with the auth diagnostics sshx
|
||||||
|
// collected, which is usually what actually explains it.
|
||||||
|
func (c *connectWindow) showConnectError(err error, detail string) {
|
||||||
|
msg := err.Error()
|
||||||
|
if detail != "" {
|
||||||
|
msg += "\n\nAuth diagnostics:\n · " + detail
|
||||||
|
}
|
||||||
|
label := widget.NewLabel(msg)
|
||||||
|
label.Wrapping = fyne.TextWrapWord
|
||||||
|
d := dialog.NewCustom("Connection failed", "Close", container.NewVScroll(label), c.win)
|
||||||
|
d.Resize(fyne.NewSize(560, 320))
|
||||||
|
d.Show()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *connectWindow) setStatus(s string) { c.status.SetText(s) }
|
||||||
|
|
||||||
|
func (c *connectWindow) showAndRun() {
|
||||||
|
c.win.ShowAndRun()
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"fyne.io/fyne/v2"
|
||||||
|
"fyne.io/fyne/v2/container"
|
||||||
|
"fyne.io/fyne/v2/dialog"
|
||||||
|
"fyne.io/fyne/v2/layout"
|
||||||
|
"fyne.io/fyne/v2/widget"
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
|
||||||
|
"gitea.apointless.space/bsncubed/haul/internal/sshx"
|
||||||
|
"gitea.apointless.space/bsncubed/haul/internal/xfer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The prompts in this file are all shaped the same way, because they are all
|
||||||
|
// answers a background goroutine is blocked waiting for: the goroutine makes a
|
||||||
|
// buffered channel, asks for the prompt to be shown on the UI thread, and reads
|
||||||
|
// its answer. Every dismissal path — button, escape, window close — must send
|
||||||
|
// exactly once, or the transfer goroutine hangs forever holding the connection.
|
||||||
|
// sync.Once is what guarantees the "exactly".
|
||||||
|
|
||||||
|
func confirmDialog(title, msg string, win fyne.Window, cb func(bool)) {
|
||||||
|
dialog.ShowConfirm(title, msg, cb, win)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFormDialog(title, confirm string, items []*widget.FormItem, win fyne.Window, cb func(bool)) dialog.Dialog {
|
||||||
|
d := dialog.NewForm(title, confirm, "Cancel", items, cb, win)
|
||||||
|
d.Resize(fyne.NewSize(420, 0))
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// conflictAnswer is the user's decision about one pre-existing destination file.
|
||||||
|
type conflictAnswer struct {
|
||||||
|
choice xfer.Conflict
|
||||||
|
all bool // apply this decision to every remaining conflict
|
||||||
|
}
|
||||||
|
|
||||||
|
// askConflict shows the overwrite prompt. It must be called on the UI thread.
|
||||||
|
func askConflict(win fyne.Window, name string, reply chan<- conflictAnswer) {
|
||||||
|
var once sync.Once
|
||||||
|
send := func(a conflictAnswer) { once.Do(func() { reply <- a }) }
|
||||||
|
|
||||||
|
all := widget.NewCheck("Apply to all remaining conflicts", nil)
|
||||||
|
msg := widget.NewLabel(fmt.Sprintf("%s already exists at the destination.", name))
|
||||||
|
msg.Wrapping = fyne.TextWrapWord
|
||||||
|
|
||||||
|
var d *dialog.CustomDialog
|
||||||
|
button := func(label string, c xfer.Conflict) *widget.Button {
|
||||||
|
return widget.NewButton(label, func() {
|
||||||
|
send(conflictAnswer{choice: c, all: all.Checked})
|
||||||
|
d.Hide()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
overwrite := button("Overwrite", xfer.Overwrite)
|
||||||
|
overwrite.Importance = widget.HighImportance
|
||||||
|
|
||||||
|
content := container.NewVBox(
|
||||||
|
msg,
|
||||||
|
all,
|
||||||
|
container.NewHBox(layout.NewSpacer(),
|
||||||
|
button("Skip", xfer.Skip),
|
||||||
|
button("Keep both", xfer.Rename),
|
||||||
|
overwrite,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
d = dialog.NewCustomWithoutButtons("File exists", content, win)
|
||||||
|
// Dismissing without choosing is treated as "skip this file": the safe
|
||||||
|
// reading of "I didn't answer" is "don't destroy anything".
|
||||||
|
d.SetOnClosed(func() { send(conflictAnswer{choice: xfer.Skip}) })
|
||||||
|
d.Resize(fyne.NewSize(460, 0))
|
||||||
|
d.Show()
|
||||||
|
}
|
||||||
|
|
||||||
|
// askSecret prompts for a password or key passphrase. An empty result means the
|
||||||
|
// user declined, which callers pass on to sshx as "skip this method".
|
||||||
|
func askSecret(win fyne.Window, title, prompt string, reply chan<- []byte) {
|
||||||
|
var once sync.Once
|
||||||
|
send := func(b []byte) { once.Do(func() { reply <- b }) }
|
||||||
|
|
||||||
|
entry := widget.NewPasswordEntry()
|
||||||
|
label := widget.NewLabel(prompt)
|
||||||
|
label.Wrapping = fyne.TextWrapWord
|
||||||
|
|
||||||
|
var d *dialog.CustomDialog
|
||||||
|
submit := func() {
|
||||||
|
send([]byte(entry.Text))
|
||||||
|
d.Hide()
|
||||||
|
}
|
||||||
|
entry.OnSubmitted = func(string) { submit() }
|
||||||
|
|
||||||
|
ok := widget.NewButton("OK", submit)
|
||||||
|
ok.Importance = widget.HighImportance
|
||||||
|
cancel := widget.NewButton("Cancel", func() {
|
||||||
|
send(nil)
|
||||||
|
d.Hide()
|
||||||
|
})
|
||||||
|
|
||||||
|
content := container.NewVBox(
|
||||||
|
label,
|
||||||
|
entry,
|
||||||
|
container.NewHBox(layout.NewSpacer(), cancel, ok),
|
||||||
|
)
|
||||||
|
d = dialog.NewCustomWithoutButtons(title, content, win)
|
||||||
|
d.SetOnClosed(func() { send(nil) })
|
||||||
|
d.Resize(fyne.NewSize(420, 0))
|
||||||
|
d.Show()
|
||||||
|
win.Canvas().Focus(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
// askHostKey shows the unknown-host prompt, the GUI equivalent of ssh's
|
||||||
|
// "authenticity of host ... can't be established". The fingerprint is shown in
|
||||||
|
// the same SHA256 form ssh prints, so it can be compared by eye.
|
||||||
|
func askHostKey(win fyne.Window, host string, key ssh.PublicKey, reply chan<- bool) {
|
||||||
|
var once sync.Once
|
||||||
|
send := func(b bool) { once.Do(func() { reply <- b }) }
|
||||||
|
|
||||||
|
msg := widget.NewLabel(fmt.Sprintf(
|
||||||
|
"The authenticity of host %s can't be established.\n\n%s key fingerprint:", host, key.Type()))
|
||||||
|
msg.Wrapping = fyne.TextWrapWord
|
||||||
|
fp := widget.NewLabelWithStyle(sshx.FingerprintSHA256(key), fyne.TextAlignCenter, fyne.TextStyle{Monospace: true})
|
||||||
|
warn := widget.NewLabel("Only continue if you recognise this fingerprint. It will be added to your known_hosts file.")
|
||||||
|
warn.Wrapping = fyne.TextWrapWord
|
||||||
|
|
||||||
|
var d *dialog.CustomDialog
|
||||||
|
accept := widget.NewButton("Connect and record key", func() {
|
||||||
|
send(true)
|
||||||
|
d.Hide()
|
||||||
|
})
|
||||||
|
accept.Importance = widget.HighImportance
|
||||||
|
reject := widget.NewButton("Cancel", func() {
|
||||||
|
send(false)
|
||||||
|
d.Hide()
|
||||||
|
})
|
||||||
|
|
||||||
|
content := container.NewVBox(msg, fp, warn,
|
||||||
|
container.NewHBox(layout.NewSpacer(), reject, accept))
|
||||||
|
d = dialog.NewCustomWithoutButtons("Unknown host key", content, win)
|
||||||
|
d.SetOnClosed(func() { send(false) })
|
||||||
|
d.Resize(fyne.NewSize(520, 0))
|
||||||
|
d.Show()
|
||||||
|
}
|
||||||
@@ -0,0 +1,442 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"fyne.io/fyne/v2"
|
||||||
|
"fyne.io/fyne/v2/container"
|
||||||
|
"fyne.io/fyne/v2/theme"
|
||||||
|
"fyne.io/fyne/v2/widget"
|
||||||
|
|
||||||
|
"gitea.apointless.space/bsncubed/haul/internal/vfs"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Column layout, shared by the header and the cells.
|
||||||
|
const (
|
||||||
|
colMark = iota
|
||||||
|
colName
|
||||||
|
colSize
|
||||||
|
colModified
|
||||||
|
numCols
|
||||||
|
)
|
||||||
|
|
||||||
|
// item is one row of a directory listing.
|
||||||
|
type item struct {
|
||||||
|
name string
|
||||||
|
isDir bool
|
||||||
|
size int64
|
||||||
|
mod time.Time
|
||||||
|
up bool // the synthetic ".." row
|
||||||
|
}
|
||||||
|
|
||||||
|
// pane is one side of the browser: a path bar, a file table, and a footer. Both
|
||||||
|
// the local disk and the remote host are the same widget over a different
|
||||||
|
// vfs.FS, so anything that works on one works on the other.
|
||||||
|
type pane struct {
|
||||||
|
fs vfs.FS
|
||||||
|
title string
|
||||||
|
|
||||||
|
cwd string
|
||||||
|
entries []item
|
||||||
|
marked map[string]bool // names in cwd marked for transfer
|
||||||
|
cursor int // highlighted row, or -1 for none
|
||||||
|
|
||||||
|
table *widget.Table
|
||||||
|
pathEntry *widget.Entry
|
||||||
|
footer *widget.Label
|
||||||
|
titleLbl *widget.Label
|
||||||
|
root fyne.CanvasObject
|
||||||
|
|
||||||
|
win fyne.Window
|
||||||
|
onError func(error)
|
||||||
|
// onFocus tells the session which pane the user last touched, so toolbar
|
||||||
|
// actions and keyboard shortcuts know what they apply to.
|
||||||
|
onFocus func(*pane)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPane(win fyne.Window, filesys vfs.FS, title string, onError func(error), onFocus func(*pane)) *pane {
|
||||||
|
p := &pane{
|
||||||
|
fs: filesys,
|
||||||
|
title: title,
|
||||||
|
marked: map[string]bool{},
|
||||||
|
cursor: -1,
|
||||||
|
win: win,
|
||||||
|
onError: onError,
|
||||||
|
onFocus: onFocus,
|
||||||
|
}
|
||||||
|
|
||||||
|
p.titleLbl = widget.NewLabelWithStyle(title, fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
|
||||||
|
p.footer = widget.NewLabel("")
|
||||||
|
|
||||||
|
p.pathEntry = widget.NewEntry()
|
||||||
|
p.pathEntry.SetPlaceHolder("path")
|
||||||
|
// Enter in the path bar jumps straight there — the fastest way to a deep
|
||||||
|
// directory, and the reason the path is an entry rather than a label.
|
||||||
|
p.pathEntry.OnSubmitted = func(s string) {
|
||||||
|
p.navigate(vfs.CleanPath(p.fs, s))
|
||||||
|
}
|
||||||
|
|
||||||
|
p.table = widget.NewTable(p.tableSize, p.createCell, p.updateCell)
|
||||||
|
p.table.ShowHeaderRow = true
|
||||||
|
p.table.CreateHeader = func() fyne.CanvasObject {
|
||||||
|
return widget.NewLabelWithStyle("", fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
|
||||||
|
}
|
||||||
|
p.table.UpdateHeader = p.updateHeader
|
||||||
|
p.table.OnSelected = p.onSelect
|
||||||
|
p.table.SetColumnWidth(colMark, 32)
|
||||||
|
p.table.SetColumnWidth(colName, 300)
|
||||||
|
p.table.SetColumnWidth(colSize, 96)
|
||||||
|
p.table.SetColumnWidth(colModified, 150)
|
||||||
|
|
||||||
|
upBtn := widget.NewButtonWithIcon("", theme.MoveUpIcon(), func() { p.up() })
|
||||||
|
homeBtn := widget.NewButtonWithIcon("", theme.HomeIcon(), func() { p.navigate(p.fs.Home()) })
|
||||||
|
refreshBtn := widget.NewButtonWithIcon("", theme.ViewRefreshIcon(), func() { p.reload() })
|
||||||
|
|
||||||
|
nav := container.NewBorder(nil, nil,
|
||||||
|
container.NewHBox(upBtn, homeBtn),
|
||||||
|
refreshBtn,
|
||||||
|
p.pathEntry,
|
||||||
|
)
|
||||||
|
|
||||||
|
actions := container.NewHBox(
|
||||||
|
widget.NewButtonWithIcon("New folder", theme.FolderNewIcon(), p.promptMkdir),
|
||||||
|
widget.NewButtonWithIcon("Rename", theme.DocumentCreateIcon(), p.promptRename),
|
||||||
|
widget.NewButtonWithIcon("Delete", theme.DeleteIcon(), p.promptDelete),
|
||||||
|
)
|
||||||
|
|
||||||
|
top := container.NewVBox(p.titleLbl, nav)
|
||||||
|
bottom := container.NewVBox(actions, p.footer)
|
||||||
|
p.root = container.NewBorder(top, bottom, nil, nil, p.table)
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- table plumbing ---
|
||||||
|
|
||||||
|
func (p *pane) tableSize() (int, int) { return len(p.entries), numCols }
|
||||||
|
|
||||||
|
func (p *pane) createCell() fyne.CanvasObject {
|
||||||
|
l := widget.NewLabel("")
|
||||||
|
l.Truncation = fyne.TextTruncateEllipsis
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *pane) updateCell(id widget.TableCellID, o fyne.CanvasObject) {
|
||||||
|
l, ok := o.(*widget.Label)
|
||||||
|
if !ok || id.Row < 0 || id.Row >= len(p.entries) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
it := p.entries[id.Row]
|
||||||
|
// Fields are assigned rather than SetText'd so each cell refreshes once;
|
||||||
|
// update runs for every visible cell on every scroll tick.
|
||||||
|
l.Alignment = fyne.TextAlignLeading
|
||||||
|
l.TextStyle = fyne.TextStyle{}
|
||||||
|
|
||||||
|
switch id.Col {
|
||||||
|
case colMark:
|
||||||
|
l.Alignment = fyne.TextAlignCenter
|
||||||
|
l.Text = ""
|
||||||
|
if p.marked[it.name] {
|
||||||
|
l.Text = "✓"
|
||||||
|
}
|
||||||
|
case colName:
|
||||||
|
// Directories are bold and slash-suffixed: at a glance you can tell what
|
||||||
|
// you can descend into without hunting for an icon column.
|
||||||
|
if it.isDir {
|
||||||
|
l.TextStyle = fyne.TextStyle{Bold: true}
|
||||||
|
l.Text = it.name + "/"
|
||||||
|
} else {
|
||||||
|
l.Text = it.name
|
||||||
|
}
|
||||||
|
case colSize:
|
||||||
|
l.Alignment = fyne.TextAlignTrailing
|
||||||
|
l.Text = ""
|
||||||
|
if !it.isDir && !it.up {
|
||||||
|
l.Text = humanBytes(it.size)
|
||||||
|
}
|
||||||
|
case colModified:
|
||||||
|
l.Text = ""
|
||||||
|
if !it.up && !it.mod.IsZero() {
|
||||||
|
l.Text = it.mod.Format("2006-01-02 15:04")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
l.Refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *pane) updateHeader(id widget.TableCellID, o fyne.CanvasObject) {
|
||||||
|
l, ok := o.(*widget.Label)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l.Alignment = fyne.TextAlignLeading
|
||||||
|
l.Text = ""
|
||||||
|
switch id.Col {
|
||||||
|
case colMark:
|
||||||
|
l.Alignment = fyne.TextAlignCenter
|
||||||
|
l.Text = "✓"
|
||||||
|
case colName:
|
||||||
|
l.Text = "Name"
|
||||||
|
case colSize:
|
||||||
|
l.Alignment = fyne.TextAlignTrailing
|
||||||
|
l.Text = "Size"
|
||||||
|
case colModified:
|
||||||
|
l.Text = "Modified"
|
||||||
|
}
|
||||||
|
l.Refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
// onSelect turns a cell click into the action that cell means: the tick column
|
||||||
|
// marks a row for transfer, a directory name descends into it, and anything
|
||||||
|
// else just moves the highlight.
|
||||||
|
func (p *pane) onSelect(id widget.TableCellID) {
|
||||||
|
if p.onFocus != nil {
|
||||||
|
p.onFocus(p)
|
||||||
|
}
|
||||||
|
if id.Row < 0 || id.Row >= len(p.entries) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.cursor = id.Row
|
||||||
|
it := p.entries[id.Row]
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case id.Col == colMark && !it.up:
|
||||||
|
if p.marked[it.name] {
|
||||||
|
delete(p.marked, it.name)
|
||||||
|
} else {
|
||||||
|
p.marked[it.name] = true
|
||||||
|
}
|
||||||
|
p.table.Refresh()
|
||||||
|
p.updateFooter()
|
||||||
|
case id.Col == colName && it.up:
|
||||||
|
p.up()
|
||||||
|
case id.Col == colName && it.isDir:
|
||||||
|
p.navigate(p.fs.Join(p.cwd, it.name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- navigation ---
|
||||||
|
|
||||||
|
func (p *pane) up() {
|
||||||
|
if p.fs.IsRoot(p.cwd) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.navigate(p.fs.Dir(p.cwd))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *pane) reload() { p.navigateKeepMarks(p.cwd, true) }
|
||||||
|
|
||||||
|
// navigate moves to dir, clearing marks — they refer to names in the directory
|
||||||
|
// being left, so carrying them across would transfer the wrong thing.
|
||||||
|
func (p *pane) navigate(dir string) { p.navigateKeepMarks(dir, false) }
|
||||||
|
|
||||||
|
// navigateKeepMarks does the directory read off the UI thread. Remote listings
|
||||||
|
// are network round-trips, and a GUI that freezes while one is in flight is the
|
||||||
|
// main thing that makes an SFTP client feel slow.
|
||||||
|
func (p *pane) navigateKeepMarks(dir string, keepMarks bool) {
|
||||||
|
p.pathEntry.SetText(dir)
|
||||||
|
go func() {
|
||||||
|
entries, err := readDir(p.fs, dir)
|
||||||
|
fyne.Do(func() {
|
||||||
|
if err != nil {
|
||||||
|
// Put the box back to where we actually still are.
|
||||||
|
p.pathEntry.SetText(p.cwd)
|
||||||
|
p.onError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sameDir := dir == p.cwd
|
||||||
|
p.cwd = dir
|
||||||
|
p.entries = entries
|
||||||
|
if !keepMarks || !sameDir {
|
||||||
|
p.marked = map[string]bool{}
|
||||||
|
} else {
|
||||||
|
p.pruneMarks()
|
||||||
|
}
|
||||||
|
p.table.UnselectAll()
|
||||||
|
p.cursor = -1
|
||||||
|
p.table.Refresh()
|
||||||
|
if !sameDir {
|
||||||
|
p.table.ScrollToTop()
|
||||||
|
}
|
||||||
|
p.updateFooter()
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// pruneMarks drops marks for names that no longer exist after a refresh.
|
||||||
|
func (p *pane) pruneMarks() {
|
||||||
|
present := make(map[string]bool, len(p.entries))
|
||||||
|
for _, it := range p.entries {
|
||||||
|
present[it.name] = true
|
||||||
|
}
|
||||||
|
for name := range p.marked {
|
||||||
|
if !present[name] {
|
||||||
|
delete(p.marked, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// readDir lists dir, sorting directories first then by name, and prepends the
|
||||||
|
// ".." row unless dir is a root.
|
||||||
|
func readDir(filesys vfs.FS, dir string) ([]item, error) {
|
||||||
|
infos, err := filesys.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items := make([]item, 0, len(infos)+1)
|
||||||
|
for _, fi := range infos {
|
||||||
|
items = append(items, item{
|
||||||
|
name: fi.Name(),
|
||||||
|
isDir: fi.IsDir(),
|
||||||
|
size: fi.Size(),
|
||||||
|
mod: fi.ModTime(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sort.SliceStable(items, func(i, j int) bool {
|
||||||
|
if items[i].isDir != items[j].isDir {
|
||||||
|
return items[i].isDir
|
||||||
|
}
|
||||||
|
return items[i].name < items[j].name
|
||||||
|
})
|
||||||
|
if !filesys.IsRoot(dir) {
|
||||||
|
items = append([]item{{name: "..", isDir: true, up: true}}, items...)
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- selection ---
|
||||||
|
|
||||||
|
// sources returns the absolute paths a transfer should act on: everything
|
||||||
|
// marked, or the highlighted row if nothing is marked. That second case is what
|
||||||
|
// makes the common "send this one file" case a two-click operation.
|
||||||
|
func (p *pane) sources() []string {
|
||||||
|
if len(p.marked) > 0 {
|
||||||
|
out := make([]string, 0, len(p.marked))
|
||||||
|
// Walk entries, not the map, so the order matches what's on screen.
|
||||||
|
for _, it := range p.entries {
|
||||||
|
if p.marked[it.name] {
|
||||||
|
out = append(out, p.fs.Join(p.cwd, it.name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
if it, ok := p.highlighted(); ok && !it.up {
|
||||||
|
return []string{p.fs.Join(p.cwd, it.name)}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// highlighted returns the row the user last clicked, if it is still in range.
|
||||||
|
func (p *pane) highlighted() (item, bool) {
|
||||||
|
if p.cursor < 0 || p.cursor >= len(p.entries) {
|
||||||
|
return item{}, false
|
||||||
|
}
|
||||||
|
return p.entries[p.cursor], true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *pane) updateFooter() {
|
||||||
|
var files, dirs int
|
||||||
|
var bytes int64
|
||||||
|
for _, it := range p.entries {
|
||||||
|
switch {
|
||||||
|
case it.up:
|
||||||
|
case it.isDir:
|
||||||
|
dirs++
|
||||||
|
default:
|
||||||
|
files++
|
||||||
|
bytes += it.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
text := fmt.Sprintf("%d dirs, %d files (%s)", dirs, files, humanBytes(bytes))
|
||||||
|
if n := len(p.marked); n > 0 {
|
||||||
|
text += fmt.Sprintf(" — %d marked", n)
|
||||||
|
}
|
||||||
|
p.footer.SetText(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- file operations ---
|
||||||
|
|
||||||
|
func (p *pane) promptMkdir() {
|
||||||
|
entry := widget.NewEntry()
|
||||||
|
entry.SetPlaceHolder("name")
|
||||||
|
formItem := widget.NewFormItem("Folder", entry)
|
||||||
|
d := newFormDialog("New folder in "+p.cwd, "Create", []*widget.FormItem{formItem}, p.win, func(ok bool) {
|
||||||
|
if !ok || entry.Text == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.runOp(func() error { return p.fs.Mkdir(p.fs.Join(p.cwd, entry.Text)) })
|
||||||
|
})
|
||||||
|
d.Show()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *pane) promptRename() {
|
||||||
|
it, ok := p.highlighted()
|
||||||
|
if !ok || it.up {
|
||||||
|
p.onError(fmt.Errorf("select a file or folder to rename"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entry := widget.NewEntry()
|
||||||
|
entry.SetText(it.name)
|
||||||
|
formItem := widget.NewFormItem("New name", entry)
|
||||||
|
d := newFormDialog("Rename "+it.name, "Rename", []*widget.FormItem{formItem}, p.win, func(ok bool) {
|
||||||
|
if !ok || entry.Text == "" || entry.Text == it.name {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.runOp(func() error {
|
||||||
|
return p.fs.Rename(p.fs.Join(p.cwd, it.name), p.fs.Join(p.cwd, entry.Text))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
d.Show()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *pane) promptDelete() {
|
||||||
|
targets := p.sources()
|
||||||
|
if len(targets) == 0 {
|
||||||
|
p.onError(fmt.Errorf("select or mark something to delete"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg := fmt.Sprintf("Delete %d item(s) from %s?\n\nDirectories are deleted with everything inside them. This cannot be undone.",
|
||||||
|
len(targets), p.title)
|
||||||
|
confirmDialog("Confirm delete", msg, p.win, func(ok bool) {
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.runOp(func() error {
|
||||||
|
for _, t := range targets {
|
||||||
|
if err := p.fs.Remove(t); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// runOp performs a filesystem mutation off the UI thread and refreshes the pane
|
||||||
|
// when it finishes.
|
||||||
|
func (p *pane) runOp(fn func() error) {
|
||||||
|
go func() {
|
||||||
|
err := fn()
|
||||||
|
fyne.Do(func() {
|
||||||
|
if err != nil {
|
||||||
|
p.onError(err)
|
||||||
|
}
|
||||||
|
p.marked = map[string]bool{}
|
||||||
|
p.reload()
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// humanBytes formats a byte count in units that fit a narrow column.
|
||||||
|
func humanBytes(n int64) string {
|
||||||
|
const unit = 1024
|
||||||
|
if n < unit {
|
||||||
|
return fmt.Sprintf("%d B", n)
|
||||||
|
}
|
||||||
|
div, exp := int64(unit), 0
|
||||||
|
for m := n / unit; m >= unit && exp < 4; m /= unit {
|
||||||
|
div *= unit
|
||||||
|
exp++
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTP"[exp])
|
||||||
|
}
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"fyne.io/fyne/v2"
|
||||||
|
"fyne.io/fyne/v2/container"
|
||||||
|
"fyne.io/fyne/v2/dialog"
|
||||||
|
"fyne.io/fyne/v2/driver/desktop"
|
||||||
|
"fyne.io/fyne/v2/theme"
|
||||||
|
"fyne.io/fyne/v2/widget"
|
||||||
|
"github.com/pkg/sftp"
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
|
||||||
|
"gitea.apointless.space/bsncubed/haul/internal/vfs"
|
||||||
|
"gitea.apointless.space/bsncubed/haul/internal/xfer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// session is one connected host: a window with the local disk on the left, the
|
||||||
|
// remote host on the right, and the transfer controls between them. It owns the
|
||||||
|
// SSH and SFTP connections and closes them when the window closes.
|
||||||
|
type session struct {
|
||||||
|
win fyne.Window
|
||||||
|
client *ssh.Client
|
||||||
|
sf *sftp.Client
|
||||||
|
engine *xfer.Engine
|
||||||
|
|
||||||
|
local *pane
|
||||||
|
remote *pane
|
||||||
|
active *pane
|
||||||
|
|
||||||
|
uploadBtn *widget.Button
|
||||||
|
downloadBtn *widget.Button
|
||||||
|
stopBtn *widget.Button
|
||||||
|
progBar *widget.ProgressBar
|
||||||
|
progLabel *widget.Label
|
||||||
|
|
||||||
|
busy bool
|
||||||
|
// onClose brings the connection window back when this session ends.
|
||||||
|
onClose func()
|
||||||
|
}
|
||||||
|
|
||||||
|
// newSession builds and shows the browser window for an established connection.
|
||||||
|
func newSession(app fyne.App, name string, client *ssh.Client, sf *sftp.Client, onClose func()) *session {
|
||||||
|
s := &session{
|
||||||
|
win: app.NewWindow("haul — " + name),
|
||||||
|
client: client,
|
||||||
|
sf: sf,
|
||||||
|
engine: xfer.New(sf),
|
||||||
|
onClose: onClose,
|
||||||
|
}
|
||||||
|
|
||||||
|
s.local = newPane(s.win, vfs.Local{}, "LOCAL", s.showError, s.setActive)
|
||||||
|
s.remote = newPane(s.win, vfs.Remote{C: sf}, "REMOTE — "+name, s.showError, s.setActive)
|
||||||
|
s.active = s.local
|
||||||
|
|
||||||
|
// The arrows in the labels carry the direction; an icon next to them only
|
||||||
|
// competes with it, since up/down icons say nothing about left/right panes.
|
||||||
|
s.uploadBtn = widget.NewButton("Upload →", func() { s.transfer(true) })
|
||||||
|
s.downloadBtn = widget.NewButton("← Download", func() { s.transfer(false) })
|
||||||
|
// Disconnect goes through the same guard as the window's close button, so a
|
||||||
|
// running transfer is never dropped without asking.
|
||||||
|
disconnect := widget.NewButtonWithIcon("Disconnect", theme.LogoutIcon(), s.closeRequested)
|
||||||
|
|
||||||
|
s.progBar = widget.NewProgressBar()
|
||||||
|
// An empty bar sitting at 0% reads as "stuck", so it only appears once
|
||||||
|
// there's a transfer to report on, and stays afterwards showing the result.
|
||||||
|
s.progBar.Hide()
|
||||||
|
s.progLabel = widget.NewLabel("Ready")
|
||||||
|
s.stopBtn = widget.NewButtonWithIcon("Stop", theme.CancelIcon(), func() { s.engine.Cancel() })
|
||||||
|
s.stopBtn.Disable()
|
||||||
|
|
||||||
|
controls := container.NewHBox(s.uploadBtn, s.downloadBtn)
|
||||||
|
transferBar := container.NewBorder(nil, nil, controls, disconnect)
|
||||||
|
progressBar := container.NewBorder(nil, nil, nil, s.stopBtn,
|
||||||
|
container.NewBorder(nil, nil, nil, s.progLabel, s.progBar))
|
||||||
|
|
||||||
|
split := container.NewHSplit(s.local.root, s.remote.root)
|
||||||
|
split.SetOffset(0.5)
|
||||||
|
|
||||||
|
s.win.SetContent(container.NewBorder(
|
||||||
|
nil,
|
||||||
|
container.NewVBox(widget.NewSeparator(), transferBar, progressBar),
|
||||||
|
nil, nil,
|
||||||
|
split,
|
||||||
|
))
|
||||||
|
s.win.Resize(fyne.NewSize(1180, 720))
|
||||||
|
s.win.SetCloseIntercept(s.closeRequested)
|
||||||
|
s.registerShortcuts()
|
||||||
|
|
||||||
|
s.local.navigate(s.local.fs.Home())
|
||||||
|
s.remote.navigate(s.remote.fs.Home())
|
||||||
|
s.win.Show()
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *session) setActive(p *pane) { s.active = p }
|
||||||
|
|
||||||
|
func (s *session) other(p *pane) *pane {
|
||||||
|
if p == s.local {
|
||||||
|
return s.remote
|
||||||
|
}
|
||||||
|
return s.local
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *session) registerShortcuts() {
|
||||||
|
add := func(key fyne.KeyName, fn func()) {
|
||||||
|
s.win.Canvas().AddShortcut(
|
||||||
|
&desktop.CustomShortcut{KeyName: key, Modifier: fyne.KeyModifierControl},
|
||||||
|
func(fyne.Shortcut) { fn() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
add(fyne.KeyR, func() { s.active.reload() })
|
||||||
|
add(fyne.KeyUp, func() { s.active.up() })
|
||||||
|
// Ctrl+T sends the active pane's selection to the other side, so the same
|
||||||
|
// key uploads or downloads depending on which pane you were last in.
|
||||||
|
add(fyne.KeyT, func() { s.transfer(s.active == s.local) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- transfers ---
|
||||||
|
|
||||||
|
// transfer copies the source pane's marked entries (or its highlighted row)
|
||||||
|
// into the other pane's current directory.
|
||||||
|
func (s *session) transfer(push bool) {
|
||||||
|
if s.busy {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
src, dst := s.remote, s.local
|
||||||
|
if push {
|
||||||
|
src, dst = s.local, s.remote
|
||||||
|
}
|
||||||
|
sources := src.sources()
|
||||||
|
if len(sources) == 0 {
|
||||||
|
s.showError(fmt.Errorf("mark or select something in %s first", src.title))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
destDir := dst.cwd
|
||||||
|
s.setBusy(true)
|
||||||
|
s.progBar.SetValue(0)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
// applyAll lives in this goroutine alone, so the "apply to all" answer
|
||||||
|
// needs no synchronisation with the UI.
|
||||||
|
var applyAll *xfer.Conflict
|
||||||
|
|
||||||
|
s.engine.OnProgress = s.progressSink()
|
||||||
|
s.engine.OnConflict = func(name string) xfer.Conflict {
|
||||||
|
if applyAll != nil {
|
||||||
|
return *applyAll
|
||||||
|
}
|
||||||
|
reply := make(chan conflictAnswer, 1)
|
||||||
|
fyne.Do(func() { askConflict(s.win, name, reply) })
|
||||||
|
a := <-reply
|
||||||
|
if a.all {
|
||||||
|
c := a.choice
|
||||||
|
applyAll = &c
|
||||||
|
}
|
||||||
|
return a.choice
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
if push {
|
||||||
|
err = s.engine.Push(sources, destDir)
|
||||||
|
} else {
|
||||||
|
err = s.engine.Pull(sources, destDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
fyne.Do(func() {
|
||||||
|
s.setBusy(false)
|
||||||
|
src.marked = map[string]bool{}
|
||||||
|
src.table.Refresh()
|
||||||
|
src.updateFooter()
|
||||||
|
dst.reload() // show what just landed
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
s.progBar.SetValue(1)
|
||||||
|
s.progLabel.SetText(fmt.Sprintf("Transferred %d item(s)", len(sources)))
|
||||||
|
case errors.Is(err, xfer.ErrCanceled):
|
||||||
|
s.progLabel.SetText("Transfer stopped — partial files can be resumed by transferring again")
|
||||||
|
default:
|
||||||
|
s.progLabel.SetText("Transfer failed")
|
||||||
|
s.showError(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// progressSink returns the engine's progress callback, rate-limited before it
|
||||||
|
// reaches the UI thread. The engine reports on every write — tens of thousands
|
||||||
|
// of times for a large file — and repainting at that rate is slower than the
|
||||||
|
// transfer itself.
|
||||||
|
func (s *session) progressSink() xfer.ProgressFunc {
|
||||||
|
// Only the transfer goroutine calls this, so last needs no locking.
|
||||||
|
var last time.Time
|
||||||
|
const minInterval = 60 * time.Millisecond
|
||||||
|
return func(p xfer.Progress) {
|
||||||
|
now := time.Now()
|
||||||
|
done := p.FilesTotal > 0 && p.FilesDone == p.FilesTotal
|
||||||
|
if !done && now.Sub(last) < minInterval {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
last = now
|
||||||
|
fyne.Do(func() { s.paintProgress(p) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *session) paintProgress(p xfer.Progress) {
|
||||||
|
if p.BytesTotal > 0 {
|
||||||
|
s.progBar.SetValue(float64(p.BytesDone) / float64(p.BytesTotal))
|
||||||
|
}
|
||||||
|
s.progLabel.SetText(fmt.Sprintf("%d/%d files · %s of %s · %s",
|
||||||
|
p.FilesDone, p.FilesTotal, humanBytes(p.BytesDone), humanBytes(p.BytesTotal), p.CurrentName))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *session) setBusy(busy bool) {
|
||||||
|
s.busy = busy
|
||||||
|
if busy {
|
||||||
|
s.progBar.Show()
|
||||||
|
s.uploadBtn.Disable()
|
||||||
|
s.downloadBtn.Disable()
|
||||||
|
s.stopBtn.Enable()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.uploadBtn.Enable()
|
||||||
|
s.downloadBtn.Enable()
|
||||||
|
s.stopBtn.Disable()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- lifecycle ---
|
||||||
|
|
||||||
|
func (s *session) showError(err error) {
|
||||||
|
if err == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dialog.ShowError(err, s.win)
|
||||||
|
}
|
||||||
|
|
||||||
|
// closeRequested guards against closing the window out from under a running
|
||||||
|
// transfer, which would kill the connection mid-file.
|
||||||
|
func (s *session) closeRequested() {
|
||||||
|
if !s.busy {
|
||||||
|
s.shutdown()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
confirmDialog("Transfer in progress",
|
||||||
|
"A transfer is still running. Stop it and disconnect?", s.win, func(ok bool) {
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.engine.Cancel()
|
||||||
|
s.shutdown()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *session) shutdown() {
|
||||||
|
if s.sf != nil {
|
||||||
|
s.sf.Close()
|
||||||
|
}
|
||||||
|
if s.client != nil {
|
||||||
|
s.client.Close()
|
||||||
|
}
|
||||||
|
s.win.Close()
|
||||||
|
if s.onClose != nil {
|
||||||
|
s.onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
// Package ui is haul's desktop GUI: a connection window backed by ~/.ssh/config
|
||||||
|
// (plus ad-hoc quick connect), and a two-pane local/remote file browser per
|
||||||
|
// connected host.
|
||||||
|
//
|
||||||
|
// The rule the whole package follows: Fyne widgets are only ever touched from
|
||||||
|
// the UI thread. Every SFTP call — listing a directory, transferring, deleting
|
||||||
|
// — runs on its own goroutine and comes back through fyne.Do. A GUI that blocks
|
||||||
|
// its event loop on a network round-trip is exactly the thing that makes an
|
||||||
|
// SFTP client feel slow, and remote round-trips are the common case here.
|
||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image/color"
|
||||||
|
|
||||||
|
"fyne.io/fyne/v2"
|
||||||
|
"fyne.io/fyne/v2/app"
|
||||||
|
"fyne.io/fyne/v2/theme"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Run starts the GUI and blocks until the user quits.
|
||||||
|
func Run() error {
|
||||||
|
a := app.NewWithID("space.apointless.haul")
|
||||||
|
a.Settings().SetTheme(&haulTheme{base: theme.DefaultTheme()})
|
||||||
|
newConnectWindow(a).showAndRun()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// haulTheme keeps the high-contrast, low-light-friendly look haul had as a TUI:
|
||||||
|
// a near-black background, bright text, and a clearly visible selection. It also
|
||||||
|
// tightens padding, because a file manager lives or dies on how many rows fit on
|
||||||
|
// screen.
|
||||||
|
type haulTheme struct{ base fyne.Theme }
|
||||||
|
|
||||||
|
var (
|
||||||
|
bgDark = color.NRGBA{R: 0x11, G: 0x13, B: 0x18, A: 0xff}
|
||||||
|
panelDark = color.NRGBA{R: 0x1a, G: 0x1d, B: 0x24, A: 0xff}
|
||||||
|
inputDark = color.NRGBA{R: 0x0b, G: 0x0d, B: 0x11, A: 0xff}
|
||||||
|
fgDark = color.NRGBA{R: 0xf0, G: 0xf3, B: 0xf8, A: 0xff}
|
||||||
|
mutedDark = color.NRGBA{R: 0x8b, G: 0x93, B: 0xa1, A: 0xff}
|
||||||
|
separatorDark = color.NRGBA{R: 0x2c, G: 0x31, B: 0x3a, A: 0xff}
|
||||||
|
accent = color.NRGBA{R: 0x4c, G: 0x9a, B: 0xff, A: 0xff}
|
||||||
|
selectionDark = color.NRGBA{R: 0x1e, G: 0x3f, B: 0x66, A: 0xff}
|
||||||
|
hoverDark = color.NRGBA{R: 0x25, G: 0x2a, B: 0x33, A: 0xff}
|
||||||
|
)
|
||||||
|
|
||||||
|
func (t *haulTheme) Color(n fyne.ThemeColorName, v fyne.ThemeVariant) color.Color {
|
||||||
|
if v == theme.VariantLight {
|
||||||
|
// In light mode only the accent is overridden; the stock light palette
|
||||||
|
// already has the contrast this theme is trying to buy back in the dark.
|
||||||
|
if n == theme.ColorNamePrimary {
|
||||||
|
return accent
|
||||||
|
}
|
||||||
|
return t.base.Color(n, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch n {
|
||||||
|
case theme.ColorNameBackground:
|
||||||
|
return bgDark
|
||||||
|
case theme.ColorNameForeground:
|
||||||
|
return fgDark
|
||||||
|
case theme.ColorNameButton, theme.ColorNameMenuBackground, theme.ColorNameOverlayBackground:
|
||||||
|
return panelDark
|
||||||
|
case theme.ColorNameHeaderBackground:
|
||||||
|
return panelDark
|
||||||
|
case theme.ColorNameInputBackground:
|
||||||
|
return inputDark
|
||||||
|
case theme.ColorNamePrimary:
|
||||||
|
return accent
|
||||||
|
case theme.ColorNameSelection:
|
||||||
|
return selectionDark
|
||||||
|
case theme.ColorNameHover:
|
||||||
|
return hoverDark
|
||||||
|
case theme.ColorNameSeparator:
|
||||||
|
return separatorDark
|
||||||
|
case theme.ColorNamePlaceHolder, theme.ColorNameDisabled:
|
||||||
|
return mutedDark
|
||||||
|
case theme.ColorNameScrollBar:
|
||||||
|
return separatorDark
|
||||||
|
}
|
||||||
|
return t.base.Color(n, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *haulTheme) Font(s fyne.TextStyle) fyne.Resource { return t.base.Font(s) }
|
||||||
|
|
||||||
|
func (t *haulTheme) Icon(n fyne.ThemeIconName) fyne.Resource { return t.base.Icon(n) }
|
||||||
|
|
||||||
|
func (t *haulTheme) Size(n fyne.ThemeSizeName) float32 {
|
||||||
|
switch n {
|
||||||
|
case theme.SizeNamePadding:
|
||||||
|
return 3 // stock is 4; buys roughly one extra row per screenful
|
||||||
|
case theme.SizeNameInnerPadding:
|
||||||
|
return 6
|
||||||
|
case theme.SizeNameSeparatorThickness:
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return t.base.Size(n)
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
// Package vfs is the filesystem abstraction both sides of haul are driven
|
||||||
|
// through: the local disk and a remote SFTP connection look identical to the
|
||||||
|
// transfer engine (internal/xfer) and to the browser panes (internal/ui).
|
||||||
|
//
|
||||||
|
// The two differ in more than their I/O calls — local paths use the native
|
||||||
|
// separator and a platform-specific root, remote paths are always "/"-separated
|
||||||
|
// — so path manipulation is part of the interface rather than something callers
|
||||||
|
// are trusted to get right per side.
|
||||||
|
package vfs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/pkg/sftp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FS is the filesystem surface haul needs. Read/write methods cover the copy
|
||||||
|
// engine; the path helpers and the mutating operations cover the browser.
|
||||||
|
type FS interface {
|
||||||
|
// Open opens a file for reading with seek support (needed to resume).
|
||||||
|
Open(p string) (io.ReadSeekCloser, error)
|
||||||
|
// Create truncates/creates a file for writing.
|
||||||
|
Create(p string) (io.WriteCloser, error)
|
||||||
|
// OpenAt opens an existing file for writing positioned at offset, used to
|
||||||
|
// resume a partial transfer. (SFTP O_APPEND is unreliable — pkg/sftp writes
|
||||||
|
// at an explicit offset — so we seek instead.)
|
||||||
|
OpenAt(p string, offset int64) (io.WriteCloser, error)
|
||||||
|
Stat(p string) (os.FileInfo, error)
|
||||||
|
ReadDir(p string) ([]os.FileInfo, error)
|
||||||
|
MkdirAll(p string) error
|
||||||
|
Join(elem ...string) string
|
||||||
|
Base(p string) string
|
||||||
|
|
||||||
|
// Dir returns p's parent directory.
|
||||||
|
Dir(p string) string
|
||||||
|
// IsRoot reports whether p has no parent to ascend to.
|
||||||
|
IsRoot(p string) bool
|
||||||
|
// Home is the directory a pane opens at: the user's home, falling back to
|
||||||
|
// the working directory and then the root.
|
||||||
|
Home() string
|
||||||
|
// Mkdir creates a single directory.
|
||||||
|
Mkdir(p string) error
|
||||||
|
// Remove deletes a file, or a directory and everything under it.
|
||||||
|
Remove(p string) error
|
||||||
|
// Rename moves oldPath to newPath within this filesystem.
|
||||||
|
Rename(oldPath, newPath string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local is the local disk, using os + filepath (native separators).
|
||||||
|
type Local struct{}
|
||||||
|
|
||||||
|
func (Local) Open(p string) (io.ReadSeekCloser, error) { return os.Open(p) }
|
||||||
|
func (Local) Create(p string) (io.WriteCloser, error) {
|
||||||
|
return os.OpenFile(p, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||||||
|
}
|
||||||
|
func (Local) OpenAt(p string, offset int64) (io.WriteCloser, error) {
|
||||||
|
f, err := os.OpenFile(p, os.O_WRONLY, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := f.Seek(offset, io.SeekStart); err != nil {
|
||||||
|
f.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
func (Local) Stat(p string) (os.FileInfo, error) { return os.Stat(p) }
|
||||||
|
func (Local) MkdirAll(p string) error { return os.MkdirAll(p, 0o755) }
|
||||||
|
func (Local) Join(elem ...string) string { return filepath.Join(elem...) }
|
||||||
|
func (Local) Base(p string) string { return filepath.Base(p) }
|
||||||
|
func (Local) ReadDir(p string) ([]os.FileInfo, error) { return readDirInfos(p) }
|
||||||
|
func (Local) Dir(p string) string { return filepath.Dir(p) }
|
||||||
|
|
||||||
|
// IsRoot is true when p is its own parent, which is how both "/" and "C:\"
|
||||||
|
// behave under filepath.Dir.
|
||||||
|
func (Local) IsRoot(p string) bool { return p == filepath.Dir(p) }
|
||||||
|
|
||||||
|
func (l Local) Home() string {
|
||||||
|
if home, err := os.UserHomeDir(); err == nil {
|
||||||
|
return home
|
||||||
|
}
|
||||||
|
if wd, err := os.Getwd(); err == nil {
|
||||||
|
return wd
|
||||||
|
}
|
||||||
|
return string(filepath.Separator)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Local) Mkdir(p string) error { return os.Mkdir(p, 0o755) }
|
||||||
|
func (Local) Remove(p string) error { return os.RemoveAll(p) }
|
||||||
|
func (Local) Rename(a, b string) error { return os.Rename(a, b) }
|
||||||
|
|
||||||
|
func readDirInfos(p string) ([]os.FileInfo, error) {
|
||||||
|
entries, err := os.ReadDir(p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]os.FileInfo, 0, len(entries))
|
||||||
|
for _, e := range entries {
|
||||||
|
fi, err := e.Info()
|
||||||
|
if err != nil {
|
||||||
|
// A file that vanished between the listing and the stat isn't an
|
||||||
|
// error worth failing the whole directory over.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, fi)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remote is the SFTP side, using the "/"-separated path package.
|
||||||
|
type Remote struct{ C *sftp.Client }
|
||||||
|
|
||||||
|
func (r Remote) Open(p string) (io.ReadSeekCloser, error) { return r.C.Open(p) }
|
||||||
|
func (r Remote) Create(p string) (io.WriteCloser, error) {
|
||||||
|
return r.C.OpenFile(p, os.O_CREATE|os.O_WRONLY|os.O_TRUNC)
|
||||||
|
}
|
||||||
|
func (r Remote) OpenAt(p string, offset int64) (io.WriteCloser, error) {
|
||||||
|
f, err := r.C.OpenFile(p, os.O_WRONLY)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := f.Seek(offset, io.SeekStart); err != nil {
|
||||||
|
f.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
func (r Remote) Stat(p string) (os.FileInfo, error) { return r.C.Stat(p) }
|
||||||
|
func (r Remote) MkdirAll(p string) error { return r.C.MkdirAll(p) }
|
||||||
|
func (r Remote) Join(elem ...string) string { return path.Join(elem...) }
|
||||||
|
func (r Remote) Base(p string) string { return path.Base(p) }
|
||||||
|
func (r Remote) ReadDir(p string) ([]os.FileInfo, error) { return r.C.ReadDir(p) }
|
||||||
|
func (r Remote) Dir(p string) string { return path.Dir(p) }
|
||||||
|
func (r Remote) IsRoot(p string) bool { return p == "/" || p == "" }
|
||||||
|
|
||||||
|
// Home asks the server where it put us; the SFTP subsystem starts in the
|
||||||
|
// account's home directory, so Getwd is the answer without shelling out.
|
||||||
|
func (r Remote) Home() string {
|
||||||
|
if wd, err := r.C.Getwd(); err == nil && wd != "" {
|
||||||
|
return wd
|
||||||
|
}
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Remote) Mkdir(p string) error { return r.C.Mkdir(p) }
|
||||||
|
func (r Remote) Rename(a, b string) error { return r.C.Rename(a, b) }
|
||||||
|
|
||||||
|
// Remove deletes p, recursing into directories: SFTP's RemoveDirectory only
|
||||||
|
// works on empty directories, so the tree has to be walked bottom-up.
|
||||||
|
func (r Remote) Remove(p string) error {
|
||||||
|
fi, err := r.C.Stat(p)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !fi.IsDir() {
|
||||||
|
return r.C.Remove(p)
|
||||||
|
}
|
||||||
|
entries, err := r.C.ReadDir(p)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if err := r.Remove(path.Join(p, e.Name())); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r.C.RemoveDirectory(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanPath normalises user-typed input for fs: trimming stray whitespace and
|
||||||
|
// collapsing "." and ".." segments, leaving a path fs can Stat.
|
||||||
|
func CleanPath(fs FS, p string) string {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p == "" {
|
||||||
|
return fs.Home()
|
||||||
|
}
|
||||||
|
if _, ok := fs.(Remote); ok {
|
||||||
|
return path.Clean(p)
|
||||||
|
}
|
||||||
|
return filepath.Clean(p)
|
||||||
|
}
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
// Package xfer implements haul's SFTP file-transfer engine: recursive push/pull
|
||||||
|
// with aggregate progress, per-file overwrite policy, and same-session resume.
|
||||||
|
//
|
||||||
|
// Push (local→remote) and pull (remote→local) share one recursive copy routine
|
||||||
|
// by abstracting each side behind vfs.FS, so the tree-walking, resume, and
|
||||||
|
// conflict logic is written once and exercised in both directions.
|
||||||
|
package xfer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
|
"github.com/pkg/sftp"
|
||||||
|
|
||||||
|
"gitea.apointless.space/bsncubed/haul/internal/vfs"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrCanceled is returned by Push/Pull when Cancel was called mid-transfer.
|
||||||
|
// Files already copied are left in place; the partial file is left on disk so
|
||||||
|
// that re-running the same transfer on this Engine resumes it.
|
||||||
|
var ErrCanceled = errors.New("transfer canceled")
|
||||||
|
|
||||||
|
// Conflict is the decision for a destination file that already existed before
|
||||||
|
// this transfer began.
|
||||||
|
type Conflict int
|
||||||
|
|
||||||
|
const (
|
||||||
|
Overwrite Conflict = iota // replace the existing file
|
||||||
|
Skip // leave the existing file, skip the source
|
||||||
|
Rename // write the source under a non-colliding name
|
||||||
|
)
|
||||||
|
|
||||||
|
// Progress is a snapshot of an in-flight transfer, reported to OnProgress.
|
||||||
|
type Progress struct {
|
||||||
|
CurrentName string // file currently copying
|
||||||
|
FilesDone int
|
||||||
|
FilesTotal int
|
||||||
|
BytesDone int64 // cumulative across all files
|
||||||
|
BytesTotal int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConflictFunc decides what to do about a pre-existing destination file. name
|
||||||
|
// is the destination path. Returning the same decision for every call is the
|
||||||
|
// "apply to all" behaviour.
|
||||||
|
type ConflictFunc func(name string) Conflict
|
||||||
|
|
||||||
|
// ProgressFunc receives periodic progress updates. It must not block.
|
||||||
|
type ProgressFunc func(Progress)
|
||||||
|
|
||||||
|
// Engine performs transfers over one SFTP connection. A single Engine instance
|
||||||
|
// remembers which destination files it has created this session, which is what
|
||||||
|
// makes same-session resume work: re-running an interrupted transfer resumes
|
||||||
|
// partial files it started rather than treating them as pre-existing conflicts.
|
||||||
|
type Engine struct {
|
||||||
|
remote vfs.FS
|
||||||
|
OnProgress ProgressFunc
|
||||||
|
OnConflict ConflictFunc
|
||||||
|
|
||||||
|
created map[string]bool // destination paths this session started/finished
|
||||||
|
canceled atomic.Bool // set by Cancel, cleared at the start of each run
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns an Engine bound to an SFTP client.
|
||||||
|
func New(c *sftp.Client) *Engine {
|
||||||
|
return &Engine{remote: vfs.Remote{C: c}, created: map[string]bool{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel asks an in-flight transfer to stop. It is safe to call from another
|
||||||
|
// goroutine (the GUI's stop button) and takes effect at the next file or write
|
||||||
|
// boundary; Push/Pull then return ErrCanceled.
|
||||||
|
func (e *Engine) Cancel() { e.canceled.Store(true) }
|
||||||
|
|
||||||
|
// Push copies local sources into the remote directory (recursively for dirs).
|
||||||
|
func (e *Engine) Push(localSources []string, remoteDir string) error {
|
||||||
|
return e.run(vfs.Local{}, localSources, e.remote, remoteDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull copies remote sources into the local directory (recursively for dirs).
|
||||||
|
func (e *Engine) Pull(remoteSources []string, localDir string) error {
|
||||||
|
return e.run(e.remote, remoteSources, vfs.Local{}, localDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) run(src vfs.FS, sources []string, dst vfs.FS, dstDir string) error {
|
||||||
|
e.canceled.Store(false)
|
||||||
|
prog := Progress{}
|
||||||
|
// First pass: count files and total bytes for aggregate progress.
|
||||||
|
for _, s := range sources {
|
||||||
|
n, bytes, err := countTree(src, s)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
prog.FilesTotal += n
|
||||||
|
prog.BytesTotal += bytes
|
||||||
|
}
|
||||||
|
if err := dst.MkdirAll(dstDir); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, s := range sources {
|
||||||
|
target := dst.Join(dstDir, src.Base(s))
|
||||||
|
if err := e.copyTree(src, s, dst, target, &prog); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// countTree returns the file count and total byte size under p.
|
||||||
|
func countTree(src vfs.FS, p string) (int, int64, error) {
|
||||||
|
fi, err := src.Stat(p)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
if !fi.IsDir() {
|
||||||
|
return 1, fi.Size(), nil
|
||||||
|
}
|
||||||
|
entries, err := src.ReadDir(p)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
var files int
|
||||||
|
var bytes int64
|
||||||
|
for _, ent := range entries {
|
||||||
|
n, b, err := countTree(src, src.Join(p, ent.Name()))
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
files += n
|
||||||
|
bytes += b
|
||||||
|
}
|
||||||
|
return files, bytes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) copyTree(src vfs.FS, srcPath string, dst vfs.FS, dstPath string, prog *Progress) error {
|
||||||
|
if e.canceled.Load() {
|
||||||
|
return ErrCanceled
|
||||||
|
}
|
||||||
|
fi, err := src.Stat(srcPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if fi.IsDir() {
|
||||||
|
if err := dst.MkdirAll(dstPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
entries, err := src.ReadDir(srcPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, ent := range entries {
|
||||||
|
child := dst.Join(dstPath, ent.Name())
|
||||||
|
if err := e.copyTree(src, src.Join(srcPath, ent.Name()), dst, child, prog); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return e.copyFile(src, srcPath, fi.Size(), dst, dstPath, prog)
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyFile copies a single regular file, honouring conflict policy for
|
||||||
|
// pre-existing destinations and resuming partials this session started.
|
||||||
|
func (e *Engine) copyFile(src vfs.FS, srcPath string, srcSize int64, dst vfs.FS, dstPath string, prog *Progress) error {
|
||||||
|
var offset int64
|
||||||
|
|
||||||
|
if info, err := dst.Stat(dstPath); err == nil {
|
||||||
|
if e.created[dstPath] {
|
||||||
|
// Our own partial from an interrupted attempt: resume from its end.
|
||||||
|
if info.Size() <= srcSize {
|
||||||
|
offset = info.Size()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Pre-existing file: ask the caller what to do.
|
||||||
|
switch e.conflict(dstPath) {
|
||||||
|
case Skip:
|
||||||
|
prog.FilesDone++
|
||||||
|
prog.BytesDone += srcSize
|
||||||
|
e.report(prog, dst.Base(dstPath))
|
||||||
|
return nil
|
||||||
|
case Rename:
|
||||||
|
dstPath = uniqueName(dst, dstPath)
|
||||||
|
case Overwrite:
|
||||||
|
// fall through; Create truncates.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
in, err := src.Open(srcPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer in.Close()
|
||||||
|
|
||||||
|
var out io.WriteCloser
|
||||||
|
if offset > 0 {
|
||||||
|
if _, err := in.Seek(offset, io.SeekStart); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if out, err = dst.OpenAt(dstPath, offset); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else if out, err = dst.Create(dstPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
e.created[dstPath] = true
|
||||||
|
|
||||||
|
prog.BytesDone += offset // resumed bytes already count as done
|
||||||
|
pw := &progressWriter{w: out, prog: prog, name: dst.Base(dstPath), report: e.report, canceled: &e.canceled}
|
||||||
|
if _, err := io.Copy(pw, in); err != nil {
|
||||||
|
out.Close()
|
||||||
|
return fmt.Errorf("copying %s: %w", srcPath, err)
|
||||||
|
}
|
||||||
|
if err := out.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
prog.FilesDone++
|
||||||
|
e.report(prog, dst.Base(dstPath))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) conflict(name string) Conflict {
|
||||||
|
if e.OnConflict == nil {
|
||||||
|
return Overwrite
|
||||||
|
}
|
||||||
|
return e.OnConflict(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) report(prog *Progress, name string) {
|
||||||
|
if e.OnProgress == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p := *prog
|
||||||
|
p.CurrentName = name
|
||||||
|
e.OnProgress(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// uniqueName returns dstPath with a numeric suffix that doesn't yet exist.
|
||||||
|
func uniqueName(dst vfs.FS, dstPath string) string {
|
||||||
|
for i := 1; ; i++ {
|
||||||
|
cand := fmt.Sprintf("%s.%d", dstPath, i)
|
||||||
|
if _, err := dst.Stat(cand); errors.Is(err, os.ErrNotExist) || err != nil {
|
||||||
|
return cand
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// progressWriter tallies bytes as they're written and emits progress updates.
|
||||||
|
// It is also where a cancel lands mid-file, so a single large file can be
|
||||||
|
// interrupted rather than only being stoppable between files.
|
||||||
|
type progressWriter struct {
|
||||||
|
w io.Writer
|
||||||
|
prog *Progress
|
||||||
|
name string
|
||||||
|
report func(*Progress, string)
|
||||||
|
canceled *atomic.Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *progressWriter) Write(b []byte) (int, error) {
|
||||||
|
if p.canceled != nil && p.canceled.Load() {
|
||||||
|
return 0, ErrCanceled
|
||||||
|
}
|
||||||
|
n, err := p.w.Write(b)
|
||||||
|
p.prog.BytesDone += int64(n)
|
||||||
|
p.report(p.prog, p.name)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package xfer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/pkg/sftp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeConn adapts two pipes into an io.ReadWriteCloser for the SFTP server.
|
||||||
|
type fakeConn struct {
|
||||||
|
io.Reader
|
||||||
|
io.WriteCloser
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTestEngine spins up an in-memory SFTP server over pipes and returns an
|
||||||
|
// Engine talking to it, so transfers can be exercised without a real sshd.
|
||||||
|
func newTestEngine(t *testing.T) *Engine {
|
||||||
|
t.Helper()
|
||||||
|
// OS pipes (kernel-buffered) avoid the handshake deadlock that unbuffered
|
||||||
|
// io.Pipe causes between the SFTP client and server.
|
||||||
|
srvR, cliW, err := os.Pipe() // client writes -> server reads
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cliR, srvW, err := os.Pipe() // server writes -> client reads
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
server, err := sftp.NewServer(fakeConn{srvR, srvW})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
go server.Serve()
|
||||||
|
|
||||||
|
client, err := sftp.NewClientPipe(cliR, cliW)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Close the server first so the client's recv goroutine sees EOF and
|
||||||
|
// client.Close() can return (otherwise the two block each other).
|
||||||
|
t.Cleanup(func() { server.Close(); client.Close() })
|
||||||
|
return New(client)
|
||||||
|
}
|
||||||
|
|
||||||
|
func write(t *testing.T, p, content string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func read(t *testing.T, p string) string {
|
||||||
|
t.Helper()
|
||||||
|
b, err := os.ReadFile(p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPushPullRecursive(t *testing.T) {
|
||||||
|
e := newTestEngine(t)
|
||||||
|
local := t.TempDir()
|
||||||
|
remote := t.TempDir()
|
||||||
|
|
||||||
|
// A directory tree on the local side.
|
||||||
|
write(t, filepath.Join(local, "proj", "a.txt"), "alpha")
|
||||||
|
write(t, filepath.Join(local, "proj", "sub", "b.txt"), "bravo")
|
||||||
|
|
||||||
|
var last Progress
|
||||||
|
e.OnProgress = func(p Progress) { last = p }
|
||||||
|
|
||||||
|
if err := e.Push([]string{filepath.Join(local, "proj")}, remote); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := read(t, filepath.Join(remote, "proj", "a.txt")); got != "alpha" {
|
||||||
|
t.Errorf("a.txt = %q", got)
|
||||||
|
}
|
||||||
|
if got := read(t, filepath.Join(remote, "proj", "sub", "b.txt")); got != "bravo" {
|
||||||
|
t.Errorf("b.txt = %q", got)
|
||||||
|
}
|
||||||
|
if last.FilesDone != 2 || last.FilesTotal != 2 {
|
||||||
|
t.Errorf("files done/total = %d/%d, want 2/2", last.FilesDone, last.FilesTotal)
|
||||||
|
}
|
||||||
|
if last.BytesDone != last.BytesTotal || last.BytesTotal != 10 {
|
||||||
|
t.Errorf("bytes done/total = %d/%d, want 10/10", last.BytesDone, last.BytesTotal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull it back into a fresh local dir.
|
||||||
|
local2 := t.TempDir()
|
||||||
|
if err := e.Pull([]string{filepath.Join(remote, "proj")}, local2); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := read(t, filepath.Join(local2, "proj", "sub", "b.txt")); got != "bravo" {
|
||||||
|
t.Errorf("pulled b.txt = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResumePartial(t *testing.T) {
|
||||||
|
e := newTestEngine(t)
|
||||||
|
local := t.TempDir()
|
||||||
|
remote := t.TempDir()
|
||||||
|
|
||||||
|
write(t, filepath.Join(local, "big.txt"), "hello world") // 11 bytes
|
||||||
|
// Simulate an interrupted prior attempt: a 5-byte partial that THIS engine
|
||||||
|
// created, so it should resume (append) rather than restart or conflict.
|
||||||
|
dst := filepath.Join(remote, "big.txt")
|
||||||
|
write(t, dst, "hello")
|
||||||
|
e.created[dst] = true
|
||||||
|
|
||||||
|
if err := e.Push([]string{filepath.Join(local, "big.txt")}, remote); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := read(t, dst); got != "hello world" {
|
||||||
|
t.Errorf("resume result = %q, want 'hello world'", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConflictPolicies(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
policy Conflict
|
||||||
|
wantDst string // content at original dst path
|
||||||
|
wantCopy string // content at dst.1 (only for Rename), "" if none
|
||||||
|
}{
|
||||||
|
{"skip", Skip, "OLD", ""},
|
||||||
|
{"overwrite", Overwrite, "NEW", ""},
|
||||||
|
{"rename", Rename, "OLD", "NEW"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
e := newTestEngine(t)
|
||||||
|
local := t.TempDir()
|
||||||
|
remote := t.TempDir()
|
||||||
|
write(t, filepath.Join(local, "f.txt"), "NEW")
|
||||||
|
dst := filepath.Join(remote, "f.txt")
|
||||||
|
write(t, dst, "OLD") // pre-existing, engine did NOT create it
|
||||||
|
e.OnConflict = func(string) Conflict { return tc.policy }
|
||||||
|
|
||||||
|
if err := e.Push([]string{filepath.Join(local, "f.txt")}, remote); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := read(t, dst); got != tc.wantDst {
|
||||||
|
t.Errorf("dst = %q, want %q", got, tc.wantDst)
|
||||||
|
}
|
||||||
|
if tc.wantCopy != "" {
|
||||||
|
if got := read(t, dst+".1"); got != tc.wantCopy {
|
||||||
|
t.Errorf("renamed copy = %q, want %q", got, tc.wantCopy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user