aboutsummaryrefslogtreecommitdiff
path: root/rsh
diff options
context:
space:
mode:
authorLena <lena@omega>2026-01-01 00:00:00 +0000
committerLena <lena@omega>2026-01-01 00:00:00 +0000
commit7e04941bccb2683f8a6e3ee38a99c50129234dd1 (patch)
tree471227fa437291e7a6b499e3de6c106c54eaf311 /rsh
downloadrsend-7e04941bccb2683f8a6e3ee38a99c50129234dd1.tar.gz
rsend: push phone folders to a home SSH host over rsync
A small Android app for one-way folder backup, a KISS alternative to Syncthing. It bundles rsync (built from pinned source via the NDK) and a pure-Go SSH transport, both shipped in the APK as lib*.so and run from the native library directory. rsend pins the host key, stores the ed25519 identity Keystore-encrypted, pushes each folder additively or as a mirror, and runs on demand or on a WiFi-only schedule. The build is self-contained and reproducible: make setup provisions the toolchain, make builds rsync, rsh, and the APK.
Diffstat (limited to 'rsh')
-rwxr-xr-xrsh/build.sh49
-rw-r--r--rsh/e2e_test.go84
-rw-r--r--rsh/go.mod7
-rw-r--r--rsh/go.sum6
-rw-r--r--rsh/main.go273
-rw-r--r--rsh/sshserver_test.go141
-rw-r--r--rsh/transport_test.go224
7 files changed, 784 insertions, 0 deletions
diff --git a/rsh/build.sh b/rsh/build.sh
new file mode 100755
index 0000000..88cdf9c
--- /dev/null
+++ b/rsh/build.sh
@@ -0,0 +1,49 @@
+#!/bin/sh
+# Build the rsh SSH transport for each Android ABI as a Bionic PIE executable
+# named lib*.so (packaged inside the APK and exec'd from nativeLibraryDir),
+# using the Android NDK. Also build a plain host binary for tests and manual
+# debugging.
+#
+# Android needs PIE and the Bionic dynamic linker (/system/bin/linker64), so
+# the on-device build is GOOS=android with cgo and the NDK clang. A GOOS=linux
+# build targets glibc's linker and will not exec on a phone.
+set -eu
+
+root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
+. "$root/versions"
+
+# Locate the NDK toolchain.
+ndk=${ANDROID_NDK_HOME:-${ANDROID_NDK_ROOT:-}}
+if [ -z "$ndk" ] && [ -n "${ANDROID_HOME:-}" ]; then
+ ndk="$ANDROID_HOME/ndk/$ANDROID_NDK"
+fi
+if [ -z "$ndk" ] || [ ! -d "$ndk" ]; then
+ echo "rsh: Android NDK not found; set ANDROID_NDK_HOME" >&2
+ exit 1
+fi
+tc="$ndk/toolchains/llvm/prebuilt/linux-x86_64/bin"
+
+ldflags="-s -w -buildid="
+
+cd "$root/rsh"
+
+for abi in $ABIS; do
+ case "$abi" in
+ arm64-v8a) goarch=arm64 goarm= cc="aarch64-linux-android${ANDROID_MIN_SDK}-clang" ;;
+ armeabi-v7a) goarch=arm goarm=7 cc="armv7a-linux-androideabi${ANDROID_MIN_SDK}-clang" ;;
+ x86_64) goarch=amd64 goarm= cc="x86_64-linux-android${ANDROID_MIN_SDK}-clang" ;;
+ x86) goarch=386 goarm= cc="i686-linux-android${ANDROID_MIN_SDK}-clang" ;;
+ *) echo "rsh: unknown ABI $abi" >&2; exit 1 ;;
+ esac
+ dest="$root/app/src/main/jniLibs/$abi"
+ mkdir -p "$dest"
+ echo "rsh: building $abi (android/$goarch)"
+ CGO_ENABLED=1 GOOS=android GOARCH="$goarch" GOARM="$goarm" \
+ CC="$tc/$cc" GOFLAGS=-trimpath CGO_CFLAGS="-ffile-prefix-map=$root=." \
+ go build -buildmode=pie -ldflags "$ldflags" -o "$dest/libxrsh.so" .
+done
+
+# Host binary (no NDK, cgo off) for tests and manual debugging.
+mkdir -p "$root/out"
+echo "rsh: building host binary -> out/rsh"
+CGO_ENABLED=0 GOFLAGS=-trimpath go build -ldflags "$ldflags" -o "$root/out/rsh" .
diff --git a/rsh/e2e_test.go b/rsh/e2e_test.go
new file mode 100644
index 0000000..1fa80c7
--- /dev/null
+++ b/rsh/e2e_test.go
@@ -0,0 +1,84 @@
+package main
+
+import (
+ "bytes"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "testing"
+)
+
+// TestEndToEndRealRsync drives the actual rsync binary through the actual rsh
+// transport binary into the in-process SSH server, which runs the remote rsync
+// via /bin/sh. It proves real rsync + the Go transport move files intact.
+func TestEndToEndRealRsync(t *testing.T) {
+ if testing.Short() {
+ t.Skip("short mode")
+ }
+ rsyncBin, err := exec.LookPath("rsync")
+ if err != nil {
+ t.Skip("rsync not installed")
+ }
+
+ // Build the rsh binary so rsync can exec it as its remote shell.
+ rshBin := filepath.Join(t.TempDir(), "rsh")
+ if out, err := exec.Command("go", "build", "-o", rshBin, ".").CombinedOutput(); err != nil {
+ t.Fatalf("build rsh: %v\n%s", err, out)
+ }
+
+ keyPath, pub := genClientKey(t)
+ srv := newTestServer(t, pub, shellExec)
+ kh := writeKnownHosts(t, "127.0.0.1", srv.port(), srv.hostKey.PublicKey())
+
+ src := t.TempDir()
+ dst := t.TempDir()
+ mustWrite(t, filepath.Join(src, "a.txt"), "alpha")
+ mustWrite(t, filepath.Join(src, "sub", "b.bin"), "\x00\x01\x02\x03beta")
+ mustWrite(t, filepath.Join(src, "sub", "c.txt"), "gamma gamma gamma")
+
+ cmd := exec.Command(rsyncBin, "-a", "-e", rshBin, src+"/", "u@127.0.0.1:"+dst+"/")
+ cmd.Env = append(os.Environ(),
+ "RSH_KEY="+keyPath,
+ "RSH_KNOWN_HOSTS="+kh,
+ "RSH_PORT="+strconv.Itoa(srv.port()),
+ )
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("rsync: %v\n%s", err, out)
+ }
+
+ checkSame(t, src, dst)
+}
+
+func mustWrite(t *testing.T, path, content string) {
+ t.Helper()
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func checkSame(t *testing.T, src, dst string) {
+ t.Helper()
+ err := filepath.Walk(src, func(p string, info os.FileInfo, err error) error {
+ if err != nil || info.IsDir() {
+ return err
+ }
+ rel, _ := filepath.Rel(src, p)
+ want, _ := os.ReadFile(p)
+ got, err := os.ReadFile(filepath.Join(dst, rel))
+ if err != nil {
+ t.Errorf("missing on dest: %s (%v)", rel, err)
+ return nil
+ }
+ if !bytes.Equal(got, want) {
+ t.Errorf("content mismatch for %s", rel)
+ }
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/rsh/go.mod b/rsh/go.mod
new file mode 100644
index 0000000..661c774
--- /dev/null
+++ b/rsh/go.mod
@@ -0,0 +1,7 @@
+module rsend/rsh
+
+go 1.25.0
+
+require golang.org/x/crypto v0.53.0
+
+require golang.org/x/sys v0.46.0 // indirect
diff --git a/rsh/go.sum b/rsh/go.sum
new file mode 100644
index 0000000..68cadc1
--- /dev/null
+++ b/rsh/go.sum
@@ -0,0 +1,6 @@
+golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
+golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
+golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
+golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
+golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
diff --git a/rsh/main.go b/rsh/main.go
new file mode 100644
index 0000000..31029cb
--- /dev/null
+++ b/rsh/main.go
@@ -0,0 +1,273 @@
+// rsh is rsend's SSH transport. It is invoked three ways:
+//
+// rsh [-l USER] [USER@]HOST CMD... rsync remote shell (rsync -e), strict
+// rsh -keygen DIR generate an ed25519 key, print the pubkey
+// rsh -pubkey print the pubkey for RSH_KEY_DATA/RSH_KEY
+// rsh -scan USER@HOST connect, print host-key fingerprint + line
+//
+// Transport mode mirrors what ssh does for rsync: it dials the host, runs the
+// remote command, and bridges stdin/stdout/stderr. Host keys are verified
+// strictly against RSH_KNOWN_HOSTS; an unknown or changed key fails loud.
+// Key, known_hosts path, and port come from the environment because rsync owns
+// the argument vector:
+//
+// RSH_KEY path to the ed25519 private key
+// RSH_KEY_DATA the private key itself (PEM), preferred over RSH_KEY so the
+// app need never write the plaintext key to disk
+// RSH_KNOWN_HOSTS path to the known_hosts file (required in transport mode)
+// RSH_PORT TCP port (optional, default 22)
+//
+// Pure Go, no cgo.
+package main
+
+import (
+ "crypto/ed25519"
+ "crypto/rand"
+ "encoding/pem"
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+
+ "golang.org/x/crypto/ssh"
+ "golang.org/x/crypto/ssh/knownhosts"
+)
+
+const dialTimeout = 30 * time.Second
+
+func main() {
+ if err := run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr); err != nil {
+ var ee *ssh.ExitError
+ if errors.As(err, &ee) {
+ os.Exit(ee.ExitStatus())
+ }
+ fmt.Fprintf(os.Stderr, "rsh: %v\n", err)
+ os.Exit(1)
+ }
+}
+
+func run(args []string, in io.Reader, out, errw io.Writer) error {
+ if len(args) >= 1 {
+ switch args[0] {
+ case "-keygen":
+ if len(args) != 2 {
+ return errors.New("usage: rsh -keygen DIR")
+ }
+ return keygen(args[1], out)
+ case "-pubkey":
+ if len(args) != 1 {
+ return errors.New("usage: rsh -pubkey")
+ }
+ return pubkey(out)
+ case "-scan":
+ if len(args) != 2 {
+ return errors.New("usage: rsh -scan USER@HOST")
+ }
+ return scan(args[1], out)
+ }
+ }
+ return transport(args, in, out, errw)
+}
+
+// transport runs the remote command and bridges I/O, verifying the host key
+// strictly against RSH_KNOWN_HOSTS.
+func transport(args []string, in io.Reader, out, errw io.Writer) error {
+ user, host, cmd, err := parseTransport(args)
+ if err != nil {
+ return err
+ }
+ if user == "" {
+ return errors.New("no remote user (expected -l USER or USER@HOST)")
+ }
+ if len(cmd) == 0 {
+ return errors.New("no remote command")
+ }
+ kh := os.Getenv("RSH_KNOWN_HOSTS")
+ if kh == "" {
+ return errors.New("RSH_KNOWN_HOSTS not set")
+ }
+ cb, err := knownhosts.New(kh)
+ if err != nil {
+ return fmt.Errorf("known_hosts: %w (run Test connection first)", err)
+ }
+ client, err := dial(user, host, cb)
+ if err != nil {
+ return err
+ }
+ defer client.Close()
+ go keepAlive(client)
+
+ session, err := client.NewSession()
+ if err != nil {
+ return err
+ }
+ defer session.Close()
+ session.Stdin = in
+ session.Stdout = out
+ session.Stderr = errw
+ return session.Run(strings.Join(cmd, " "))
+}
+
+// scan connects (authenticating, so it also proves the key is installed),
+// captures the presented host key, and prints its SHA256 fingerprint and a
+// known_hosts line for the caller to pin.
+func scan(target string, out io.Writer) error {
+ user, host := splitUserHost(target)
+ if user == "" {
+ return errors.New("scan target must be USER@HOST")
+ }
+ var hostKey ssh.PublicKey
+ capture := func(_ string, _ net.Addr, key ssh.PublicKey) error {
+ hostKey = key
+ return nil
+ }
+ client, err := dial(user, host, capture)
+ if err != nil {
+ return err
+ }
+ client.Close()
+
+ addr := knownhosts.Normalize(net.JoinHostPort(host, strconv.Itoa(sshPort())))
+ line := knownhosts.Line([]string{addr}, hostKey)
+ fmt.Fprintf(out, "%s\n%s\n", ssh.FingerprintSHA256(hostKey), line)
+ return nil
+}
+
+// keygen writes an ed25519 key pair into dir and prints the public key in
+// authorized_keys format to out.
+func keygen(dir string, out io.Writer) error {
+ pub, priv, err := ed25519.GenerateKey(rand.Reader)
+ if err != nil {
+ return err
+ }
+ block, err := ssh.MarshalPrivateKey(priv, "rsend")
+ if err != nil {
+ return err
+ }
+ if err := os.WriteFile(filepath.Join(dir, "id_ed25519"), pem.EncodeToMemory(block), 0o600); err != nil {
+ return err
+ }
+ sshPub, err := ssh.NewPublicKey(pub)
+ if err != nil {
+ return err
+ }
+ authLine := ssh.MarshalAuthorizedKey(sshPub)
+ if err := os.WriteFile(filepath.Join(dir, "id_ed25519.pub"), authLine, 0o644); err != nil {
+ return err
+ }
+ _, err = out.Write(authLine)
+ return err
+}
+
+// pubkey loads the private key (RSH_KEY_DATA or RSH_KEY) and prints its public
+// key in authorized_keys format. The app uses it to validate and display a key
+// the user imported: if loadSigner accepts it here, the transport will too.
+func pubkey(out io.Writer) error {
+ signer, err := loadSigner()
+ if err != nil {
+ return err
+ }
+ _, err = out.Write(ssh.MarshalAuthorizedKey(signer.PublicKey()))
+ return err
+}
+
+// loadSigner reads the private key from RSH_KEY_DATA (the key itself) or, if
+// that is unset, the file named by RSH_KEY.
+func loadSigner() (ssh.Signer, error) {
+ if data := os.Getenv("RSH_KEY_DATA"); data != "" {
+ return ssh.ParsePrivateKey([]byte(data))
+ }
+ keyPath := os.Getenv("RSH_KEY")
+ if keyPath == "" {
+ return nil, errors.New("RSH_KEY or RSH_KEY_DATA not set")
+ }
+ pemBytes, err := os.ReadFile(keyPath)
+ if err != nil {
+ return nil, fmt.Errorf("read key: %w", err)
+ }
+ return ssh.ParsePrivateKey(pemBytes)
+}
+
+// dial opens an SSH connection authenticated with the configured key, verifying
+// the host key with hostKey.
+func dial(user, host string, hostKey ssh.HostKeyCallback) (*ssh.Client, error) {
+ signer, err := loadSigner()
+ if err != nil {
+ return nil, err
+ }
+ cfg := &ssh.ClientConfig{
+ User: user,
+ Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
+ HostKeyCallback: hostKey,
+ Timeout: dialTimeout,
+ }
+ addr := net.JoinHostPort(host, strconv.Itoa(sshPort()))
+ return ssh.Dial("tcp", addr, cfg)
+}
+
+// parseTransport extracts the user, host, and remote command from the argument
+// vector rsync hands to its remote shell: an optional "-l USER", the host
+// (possibly USER@HOST), then the remote command.
+func parseTransport(args []string) (user, host string, cmd []string, err error) {
+ i := 0
+ for i < len(args) {
+ a := args[i]
+ if a == "-l" {
+ if i+1 >= len(args) {
+ return "", "", nil, errors.New("-l requires an argument")
+ }
+ user = args[i+1]
+ i += 2
+ continue
+ }
+ if strings.HasPrefix(a, "-") {
+ return "", "", nil, fmt.Errorf("unexpected option %q", a)
+ }
+ break
+ }
+ if i >= len(args) {
+ return "", "", nil, errors.New("missing host")
+ }
+ host = args[i]
+ cmd = args[i+1:]
+ if u, h := splitUserHost(host); u != "" {
+ host = h
+ if user == "" {
+ user = u
+ }
+ }
+ return user, host, cmd, nil
+}
+
+func splitUserHost(s string) (user, host string) {
+ if i := strings.Index(s, "@"); i >= 0 {
+ return s[:i], s[i+1:]
+ }
+ return "", s
+}
+
+func sshPort() int {
+ if s := os.Getenv("RSH_PORT"); s != "" {
+ if p, err := strconv.Atoi(s); err == nil && p > 0 {
+ return p
+ }
+ }
+ return 22
+}
+
+// keepAlive pings the server so a long transfer is not dropped by an idle NAT.
+// It returns once the connection is closed.
+func keepAlive(client *ssh.Client) {
+ t := time.NewTicker(60 * time.Second)
+ defer t.Stop()
+ for range t.C {
+ if _, _, err := client.SendRequest("keepalive@openssh.com", true, nil); err != nil {
+ return
+ }
+ }
+}
diff --git a/rsh/sshserver_test.go b/rsh/sshserver_test.go
new file mode 100644
index 0000000..087bff4
--- /dev/null
+++ b/rsh/sshserver_test.go
@@ -0,0 +1,141 @@
+package main
+
+import (
+ "crypto/ed25519"
+ "crypto/rand"
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "os/exec"
+ "testing"
+
+ "golang.org/x/crypto/ssh"
+)
+
+// execHandler runs the remote command for one session and returns its exit
+// code.
+type execHandler func(cmd string, stdin io.Reader, stdout, stderr io.Writer) int
+
+// testServer is a minimal in-process SSH server for tests: one host key, one
+// accepted client public key, and a pluggable exec handler. No sshd needed.
+type testServer struct {
+ ln net.Listener
+ hostKey ssh.Signer
+ authPub ssh.PublicKey
+ handle execHandler
+}
+
+func newTestServer(t *testing.T, clientPub ssh.PublicKey, handle execHandler) *testServer {
+ t.Helper()
+ _, hpriv, err := ed25519.GenerateKey(rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ signer, err := ssh.NewSignerFromKey(hpriv)
+ if err != nil {
+ t.Fatal(err)
+ }
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ s := &testServer{ln: ln, hostKey: signer, authPub: clientPub, handle: handle}
+ go s.serve()
+ t.Cleanup(func() { ln.Close() })
+ return s
+}
+
+func (s *testServer) port() int { return s.ln.Addr().(*net.TCPAddr).Port }
+
+func (s *testServer) serve() {
+ cfg := &ssh.ServerConfig{
+ PublicKeyCallback: func(_ ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
+ if s.authPub != nil && string(key.Marshal()) == string(s.authPub.Marshal()) {
+ return &ssh.Permissions{}, nil
+ }
+ return nil, fmt.Errorf("unknown public key")
+ },
+ }
+ cfg.AddHostKey(s.hostKey)
+ for {
+ nConn, err := s.ln.Accept()
+ if err != nil {
+ return
+ }
+ go s.handleConn(nConn, cfg)
+ }
+}
+
+func (s *testServer) handleConn(nConn net.Conn, cfg *ssh.ServerConfig) {
+ conn, chans, reqs, err := ssh.NewServerConn(nConn, cfg)
+ if err != nil {
+ return
+ }
+ defer conn.Close()
+ go ssh.DiscardRequests(reqs)
+ for newCh := range chans {
+ if newCh.ChannelType() != "session" {
+ newCh.Reject(ssh.UnknownChannelType, "only session")
+ continue
+ }
+ ch, requests, err := newCh.Accept()
+ if err != nil {
+ return
+ }
+ go s.handleSession(ch, requests)
+ }
+}
+
+func (s *testServer) handleSession(ch ssh.Channel, requests <-chan *ssh.Request) {
+ for req := range requests {
+ if req.Type != "exec" {
+ if req.WantReply {
+ req.Reply(false, nil)
+ }
+ continue
+ }
+ var payload struct{ Command string }
+ ssh.Unmarshal(req.Payload, &payload)
+ if req.WantReply {
+ req.Reply(true, nil)
+ }
+ code := s.handle(payload.Command, ch, ch, ch.Stderr())
+ ch.SendRequest("exit-status", false, ssh.Marshal(struct{ Status uint32 }{uint32(code)}))
+ ch.Close()
+ return
+ }
+}
+
+// shellExec runs cmd via /bin/sh -c, exactly as sshd would, wiring the channel
+// to the process. Used by the real-rsync end-to-end test.
+//
+// stdin is fed through a detached goroutine rather than c.Stdin so that Wait
+// does not block on the stdin copy: rsync's remote receiver exits while the
+// client still holds the channel open, and a c.Run with a non-file Stdin would
+// deadlock waiting for that copy to finish.
+func shellExec(cmd string, stdin io.Reader, stdout, stderr io.Writer) int {
+ c := exec.Command("/bin/sh", "-c", cmd)
+ c.Stdout = stdout
+ c.Stderr = stderr
+ inPipe, err := c.StdinPipe()
+ if err != nil {
+ return 1
+ }
+ if err := c.Start(); err != nil {
+ return 1
+ }
+ go func() {
+ io.Copy(inPipe, stdin)
+ inPipe.Close()
+ }()
+ err = c.Wait()
+ var ee *exec.ExitError
+ if errors.As(err, &ee) {
+ return ee.ExitCode()
+ }
+ if err != nil {
+ return 1
+ }
+ return 0
+}
diff --git a/rsh/transport_test.go b/rsh/transport_test.go
new file mode 100644
index 0000000..562c4d2
--- /dev/null
+++ b/rsh/transport_test.go
@@ -0,0 +1,224 @@
+package main
+
+import (
+ "bytes"
+ "errors"
+ "io"
+ "net"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "testing"
+
+ "golang.org/x/crypto/ssh"
+ "golang.org/x/crypto/ssh/knownhosts"
+)
+
+// genClientKey makes a client key via keygen and returns the private key path
+// and the parsed public key.
+func genClientKey(t *testing.T) (keyPath string, pub ssh.PublicKey) {
+ t.Helper()
+ dir := t.TempDir()
+ if err := keygen(dir, io.Discard); err != nil {
+ t.Fatal(err)
+ }
+ keyPath = filepath.Join(dir, "id_ed25519")
+ pb, err := os.ReadFile(filepath.Join(dir, "id_ed25519.pub"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ pub, _, _, _, err = ssh.ParseAuthorizedKey(pb)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return keyPath, pub
+}
+
+func writeKnownHosts(t *testing.T, host string, port int, hostKey ssh.PublicKey) string {
+ t.Helper()
+ addr := knownhosts.Normalize(net.JoinHostPort(host, strconv.Itoa(port)))
+ line := knownhosts.Line([]string{addr}, hostKey)
+ p := filepath.Join(t.TempDir(), "known_hosts")
+ if err := os.WriteFile(p, []byte(line+"\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ return p
+}
+
+func setEnv(t *testing.T, key, kh string, port int) {
+ t.Setenv("RSH_KEY", key)
+ t.Setenv("RSH_KNOWN_HOSTS", kh)
+ t.Setenv("RSH_PORT", strconv.Itoa(port))
+}
+
+func TestTransportBridgesStdio(t *testing.T) {
+ keyPath, pub := genClientKey(t)
+ echo := func(_ string, stdin io.Reader, stdout, _ io.Writer) int {
+ io.Copy(stdout, stdin)
+ return 0
+ }
+ srv := newTestServer(t, pub, echo)
+ kh := writeKnownHosts(t, "127.0.0.1", srv.port(), srv.hostKey.PublicKey())
+ setEnv(t, keyPath, kh, srv.port())
+
+ want := []byte("the quick brown fox\x00\x01\x02 binary tail")
+ var out bytes.Buffer
+ if err := transport([]string{"-l", "u", "127.0.0.1", "cat"}, bytes.NewReader(want), &out, io.Discard); err != nil {
+ t.Fatalf("transport: %v", err)
+ }
+ if !bytes.Equal(out.Bytes(), want) {
+ t.Fatalf("bridged data mismatch:\n got %q\nwant %q", out.Bytes(), want)
+ }
+}
+
+func TestTransportKeyFromData(t *testing.T) {
+ keyPath, pub := genClientKey(t)
+ echo := func(_ string, stdin io.Reader, stdout, _ io.Writer) int {
+ io.Copy(stdout, stdin)
+ return 0
+ }
+ srv := newTestServer(t, pub, echo)
+ kh := writeKnownHosts(t, "127.0.0.1", srv.port(), srv.hostKey.PublicKey())
+ keyData, err := os.ReadFile(keyPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // RSH_KEY points nowhere; RSH_KEY_DATA must take precedence and work.
+ t.Setenv("RSH_KEY", "/does/not/exist")
+ t.Setenv("RSH_KEY_DATA", string(keyData))
+ t.Setenv("RSH_KNOWN_HOSTS", kh)
+ t.Setenv("RSH_PORT", strconv.Itoa(srv.port()))
+
+ want := []byte("via key data")
+ var out bytes.Buffer
+ if err := transport([]string{"-l", "u", "127.0.0.1", "cat"}, bytes.NewReader(want), &out, io.Discard); err != nil {
+ t.Fatalf("transport: %v", err)
+ }
+ if !bytes.Equal(out.Bytes(), want) {
+ t.Fatalf("bridged data mismatch: %q", out.Bytes())
+ }
+}
+
+func TestTransportRejectsUnknownHostKey(t *testing.T) {
+ keyPath, pub := genClientKey(t)
+ srv := newTestServer(t, pub, func(_ string, _ io.Reader, _, _ io.Writer) int { return 0 })
+
+ // Pin a DIFFERENT key, so the server's real host key must be rejected.
+ _, wrong := genClientKey(t)
+ kh := writeKnownHosts(t, "127.0.0.1", srv.port(), wrong)
+ setEnv(t, keyPath, kh, srv.port())
+
+ if err := transport([]string{"-l", "u", "127.0.0.1", "true"}, bytes.NewReader(nil), io.Discard, io.Discard); err == nil {
+ t.Fatal("expected host-key mismatch error, got nil")
+ }
+}
+
+func TestTransportPropagatesExitCode(t *testing.T) {
+ keyPath, pub := genClientKey(t)
+ srv := newTestServer(t, pub, func(_ string, _ io.Reader, _, _ io.Writer) int { return 7 })
+ kh := writeKnownHosts(t, "127.0.0.1", srv.port(), srv.hostKey.PublicKey())
+ setEnv(t, keyPath, kh, srv.port())
+
+ err := transport([]string{"-l", "u", "127.0.0.1", "false"}, bytes.NewReader(nil), io.Discard, io.Discard)
+ var ee *ssh.ExitError
+ if !errors.As(err, &ee) || ee.ExitStatus() != 7 {
+ t.Fatalf("expected exit status 7, got %v", err)
+ }
+}
+
+func TestPubkeyMatchesKeygen(t *testing.T) {
+ keyPath, pub := genClientKey(t)
+ keyData, err := os.ReadFile(keyPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("RSH_KEY", "")
+ t.Setenv("RSH_KEY_DATA", string(keyData))
+
+ var out bytes.Buffer
+ if err := pubkey(&out); err != nil {
+ t.Fatalf("pubkey: %v", err)
+ }
+ got, _, _, _, err := ssh.ParseAuthorizedKey(out.Bytes())
+ if err != nil {
+ t.Fatalf("parse pubkey output: %v", err)
+ }
+ if !bytes.Equal(got.Marshal(), pub.Marshal()) {
+ t.Fatal("pubkey output does not match the keygen public key")
+ }
+}
+
+func TestPubkeyRejectsMissingKey(t *testing.T) {
+ t.Setenv("RSH_KEY", "")
+ t.Setenv("RSH_KEY_DATA", "")
+ if err := pubkey(io.Discard); err == nil {
+ t.Fatal("expected error when no key is configured")
+ }
+}
+
+func TestScanPrintsFingerprintAndLine(t *testing.T) {
+ keyPath, pub := genClientKey(t)
+ srv := newTestServer(t, pub, func(_ string, _ io.Reader, _, _ io.Writer) int { return 0 })
+ t.Setenv("RSH_KEY", keyPath)
+ t.Setenv("RSH_PORT", strconv.Itoa(srv.port()))
+
+ var out bytes.Buffer
+ if err := scan("u@127.0.0.1", &out); err != nil {
+ t.Fatalf("scan: %v", err)
+ }
+ lines := strings.SplitN(strings.TrimSpace(out.String()), "\n", 2)
+ if len(lines) != 2 {
+ t.Fatalf("want fingerprint + known_hosts line, got %q", out.String())
+ }
+ if !strings.HasPrefix(lines[0], "SHA256:") {
+ t.Errorf("fingerprint: %q", lines[0])
+ }
+ // The printed known_hosts line must validate the real host key.
+ khPath := filepath.Join(t.TempDir(), "kh")
+ if err := os.WriteFile(khPath, []byte(lines[1]+"\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ cb, err := knownhosts.New(khPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(srv.port()))
+ remote := &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: srv.port()}
+ if err := cb(addr, remote, srv.hostKey.PublicKey()); err != nil {
+ t.Errorf("pinned line did not validate the host key: %v", err)
+ }
+}
+
+func TestParseTransport(t *testing.T) {
+ cases := []struct {
+ args []string
+ user, host string
+ cmd string
+ wantErr bool
+ }{
+ {[]string{"-l", "alice", "host", "rsync", "--server"}, "alice", "host", "rsync --server", false},
+ {[]string{"bob@host", "echo", "hi"}, "bob", "host", "echo hi", false},
+ {[]string{"-l", "alice", "carol@host", "x"}, "alice", "host", "x", false},
+ {[]string{"host"}, "", "host", "", false},
+ {[]string{"-l"}, "", "", "", true},
+ {[]string{"-p", "22", "host", "x"}, "", "", "", true},
+ }
+ for _, c := range cases {
+ u, h, cmd, err := parseTransport(c.args)
+ if c.wantErr {
+ if err == nil {
+ t.Errorf("%v: expected error", c.args)
+ }
+ continue
+ }
+ if err != nil {
+ t.Errorf("%v: %v", c.args, err)
+ continue
+ }
+ if u != c.user || h != c.host || strings.Join(cmd, " ") != c.cmd {
+ t.Errorf("%v: got (%q,%q,%q) want (%q,%q,%q)",
+ c.args, u, h, strings.Join(cmd, " "), c.user, c.host, c.cmd)
+ }
+ }
+}